mirror of
https://github.com/block/buzz.git
synced 2026-11-06 17:31:19 +00:00
Merge remote-tracking branch 'origin/main' into duncan/permission-policy
Co-authored-by: Will Pfleger <pfleger.will@gmail.com> Signed-off-by: Will Pfleger <pfleger.will@gmail.com>
This commit is contained in:
@@ -7,6 +7,7 @@ import {
|
||||
useCallback,
|
||||
useEffect,
|
||||
useLayoutEffect,
|
||||
useReducer,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
@@ -20,6 +21,7 @@ import { deriveShellRoute } from "@/app/AppShell.helpers";
|
||||
import { ThemeGrainientBackground } from "@/app/ThemeGrainientBackground";
|
||||
import { CommunityThemeController } from "@/shared/theme/CommunityThemeController";
|
||||
import { useReloadShortcut } from "@/app/useReloadShortcut";
|
||||
import { useCloseWindowShortcut } from "@/app/useCloseWindowShortcut";
|
||||
import { KnownAgentPubkeysProvider } from "@/features/agents/useKnownAgentPubkeys";
|
||||
import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow";
|
||||
import { useAppOnboardingState } from "@/features/onboarding/hooks";
|
||||
@@ -61,6 +63,8 @@ import { CommunityChangeOverlay } from "@/features/communities/ui/CommunityChang
|
||||
import { setAvatarProfileSyncQueryClient } from "@/features/profile/avatarProfileSync";
|
||||
import { EncryptedBackupProvider } from "@/features/settings/EncryptedBackupProvider";
|
||||
import { createBuzzQueryClient } from "@/shared/api/queryClient";
|
||||
import { hydrateChannelHeads } from "@/features/messages/lib/channelHeadCache";
|
||||
import { useIdentityQuery } from "@/shared/api/hooks";
|
||||
import { isSharedIdentity as isSharedIdentityCmd } from "@/shared/api/tauri";
|
||||
import { getProfile } from "@/shared/api/tauriProfiles";
|
||||
import {
|
||||
@@ -210,8 +214,30 @@ function CommunitySwitchGate() {
|
||||
);
|
||||
}
|
||||
|
||||
function CommunityQueryProvider({ children }: { children: ReactNode }) {
|
||||
const [queryClient] = useState(createBuzzQueryClient);
|
||||
function CommunityQueryProvider({
|
||||
children,
|
||||
pubkey,
|
||||
relayUrl,
|
||||
}: {
|
||||
children: ReactNode;
|
||||
pubkey: string | null;
|
||||
relayUrl: string | null;
|
||||
}) {
|
||||
// Seeding persisted channel heads is part of constructing the client, not a
|
||||
// gate in front of the app: the splash, AppReady, and relay preconnect mount
|
||||
// immediately, and only the channel query waits on the cache load (see
|
||||
// channelHeadHydration). It must start here rather than in an effect —
|
||||
// React Query fires a child's queryFn when it subscribes, before any parent
|
||||
// effect runs — and StrictMode's dev-only double initializer just issues one
|
||||
// redundant read on a discarded client. The provider is keyed on the
|
||||
// community, so one client maps to one {pubkey, relayUrl} scope.
|
||||
const [queryClient] = useState(() => {
|
||||
const client = createBuzzQueryClient();
|
||||
if (pubkey && relayUrl) {
|
||||
void hydrateChannelHeads(client, { pubkey, relayUrl });
|
||||
}
|
||||
return client;
|
||||
});
|
||||
|
||||
useEffect(() => setAvatarProfileSyncQueryClient(queryClient), [queryClient]);
|
||||
|
||||
@@ -237,6 +263,39 @@ function CommunityQueryProvider({ children }: { children: ReactNode }) {
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Watches the community-scoped identity query and fires once the active
|
||||
* pubkey changes after mount — i.e. an in-app key import through the
|
||||
* relay-scoped onboarding flow, which writes the new identity to the
|
||||
* community query client only. The parent uses the signal to rebuild the
|
||||
* entire community boundary (query client, AppReady subtree, module
|
||||
* singletons via useCommunityInit) so a replacement identity never inherits
|
||||
* the previous identity's cached queries or draft-store bucket.
|
||||
*/
|
||||
function CommunityIdentityReplacementSentinel({
|
||||
onIdentityReplaced,
|
||||
}: {
|
||||
onIdentityReplaced: () => void;
|
||||
}) {
|
||||
const identityQuery = useIdentityQuery();
|
||||
const pubkey = identityQuery.data?.pubkey ?? null;
|
||||
const baselinePubkeyRef = useRef<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pubkey) return;
|
||||
if (baselinePubkeyRef.current === null) {
|
||||
baselinePubkeyRef.current = pubkey;
|
||||
return;
|
||||
}
|
||||
if (baselinePubkeyRef.current !== pubkey) {
|
||||
baselinePubkeyRef.current = pubkey;
|
||||
onIdentityReplaced();
|
||||
}
|
||||
}, [pubkey, onIdentityReplaced]);
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
function AppReady({
|
||||
isSharedIdentity,
|
||||
isCommunitySwitch,
|
||||
@@ -329,9 +388,21 @@ function CommunityApp({
|
||||
// ahead of the first apply_workspace call.
|
||||
useNestNotifications();
|
||||
|
||||
// Composite key: changes when community ID changes OR when
|
||||
// the active community's config is updated (relayUrl/token).
|
||||
const communityKey = `${activeCommunity?.id ?? "none"}-${reinitKey}`;
|
||||
// Increments when the community-scoped identity is replaced in-app (key
|
||||
// import through the relay onboarding flow). Machine-level identity changes
|
||||
// already reach this component through the currentPubkey prop; this covers
|
||||
// imports that only the community query client observes.
|
||||
const [signerEpoch, bumpSignerEpoch] = useReducer(
|
||||
(epoch: number) => epoch + 1,
|
||||
0,
|
||||
);
|
||||
|
||||
// Composite key: changes when the community ID changes, when the active
|
||||
// community's config is updated (relayUrl/token), or when the signing
|
||||
// identity is replaced. Keying CommunityQueryProvider and AppReady on the
|
||||
// signer guarantees a replacement identity never sees the previous
|
||||
// identity's query cache, React state, or draft-store bucket.
|
||||
const communityKey = `${activeCommunity?.id ?? "none"}-${reinitKey}-${currentPubkey ?? "anonymous"}-${signerEpoch}`;
|
||||
|
||||
// Latch once the community key deviates from its cold-boot value: from then
|
||||
// on, loading phases are in-app switches and get the quiet gate instead of
|
||||
@@ -553,7 +624,14 @@ function CommunityApp({
|
||||
}, [communityApplied]);
|
||||
if (appContent === null && (!transaction || isEnteringCurtain)) {
|
||||
appContent = communityApplied ? (
|
||||
<CommunityQueryProvider key={communityKey}>
|
||||
<CommunityQueryProvider
|
||||
key={communityKey}
|
||||
pubkey={community.identityPubkey}
|
||||
relayUrl={activeCommunity?.relayUrl ?? null}
|
||||
>
|
||||
<CommunityIdentityReplacementSentinel
|
||||
onIdentityReplaced={bumpSignerEpoch}
|
||||
/>
|
||||
<CommunityThemeController />
|
||||
<AppReady
|
||||
isCommunitySwitch={isCommunitySwitch}
|
||||
@@ -719,6 +797,7 @@ function MachineBootstrap({ sharedIdentity }: { sharedIdentity: boolean }) {
|
||||
|
||||
export function App() {
|
||||
useReloadShortcut();
|
||||
useCloseWindowShortcut();
|
||||
useInitialRenderReady();
|
||||
const [sharedIdentity, setSharedIdentity] = useState<boolean | null>(null);
|
||||
const [queryClient] = useState(createBuzzQueryClient);
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
import type * as React from "react";
|
||||
import * as React from "react";
|
||||
import { AppHuddleBar } from "@/app/AppHuddleBar";
|
||||
import * as BuzzTheme from "@/app/BuzzThemeSurfaces";
|
||||
import { HuddleProvider } from "@/features/huddle";
|
||||
import { HuddleProvider, useHuddle } from "@/features/huddle";
|
||||
import { HUDDLE_SHORTCUT_EVENT } from "@/shared/lib/keyboard-shortcuts";
|
||||
import { RemindMeLaterProvider } from "@/features/reminders/ui/RemindMeLaterProvider";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
@@ -19,6 +20,28 @@ type AppHuddleShellProps = {
|
||||
onVisibilityChange: (visible: boolean) => void;
|
||||
};
|
||||
|
||||
type HuddleShortcutHandlerProps = {
|
||||
children: React.ReactNode;
|
||||
};
|
||||
|
||||
function HuddleShortcutHandler({ children }: HuddleShortcutHandlerProps) {
|
||||
const { activeEphemeralChannelId, leaveHuddle } = useHuddle();
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!activeEphemeralChannelId) return;
|
||||
|
||||
function handleHuddleShortcut() {
|
||||
void leaveHuddle();
|
||||
}
|
||||
|
||||
window.addEventListener(HUDDLE_SHORTCUT_EVENT, handleHuddleShortcut);
|
||||
return () =>
|
||||
window.removeEventListener(HUDDLE_SHORTCUT_EVENT, handleHuddleShortcut);
|
||||
}, [activeEphemeralChannelId, leaveHuddle]);
|
||||
|
||||
return children;
|
||||
}
|
||||
|
||||
export function AppHuddleShell({
|
||||
children,
|
||||
currentPubkey,
|
||||
@@ -42,42 +65,44 @@ export function AppHuddleShell({
|
||||
onShowHuddleInMainApp={isRoom ? undefined : onShowHuddleInMainApp}
|
||||
onViewHuddleChannel={isRoom ? undefined : onViewHuddleChannel}
|
||||
>
|
||||
<RemindMeLaterProvider pubkey={currentPubkey}>
|
||||
<div
|
||||
className="buzz-huddle-shell relative h-dvh overflow-hidden overscroll-none"
|
||||
data-huddle-open={isDrawerOpen}
|
||||
data-huddle-window={isRoom}
|
||||
>
|
||||
<HuddleShortcutHandler>
|
||||
<RemindMeLaterProvider pubkey={currentPubkey}>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"buzz-huddle-drawer-backdrop",
|
||||
isDrawerOpen && "buzz-huddle-drawer-backdrop-open",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"buzz-huddle-app-surface z-10 flex min-h-0 flex-row overflow-hidden bg-background",
|
||||
isDrawerOpen &&
|
||||
(isRoom
|
||||
? "buzz-huddle-app-surface-room-open"
|
||||
: "buzz-huddle-app-surface-open"),
|
||||
)}
|
||||
className="buzz-huddle-shell relative h-dvh overflow-hidden overscroll-none"
|
||||
data-huddle-open={isDrawerOpen}
|
||||
data-huddle-window={isRoom}
|
||||
>
|
||||
<BuzzTheme.GradientLayer />
|
||||
{children}
|
||||
</div>
|
||||
{isRoom || !isCompanionOpen ? (
|
||||
<div className="absolute inset-x-0 bottom-0 z-[2] h-(--buzz-huddle-drawer-height)">
|
||||
<AppHuddleBar
|
||||
mode={isRoom ? "room" : "main"}
|
||||
onOpenHuddleWindow={isRoom ? undefined : onCompanionOpen}
|
||||
onVisibilityChange={onVisibilityChange}
|
||||
/>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
className={cn(
|
||||
"buzz-huddle-drawer-backdrop",
|
||||
isDrawerOpen && "buzz-huddle-drawer-backdrop-open",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"buzz-huddle-app-surface z-10 flex min-h-0 flex-row overflow-hidden bg-background",
|
||||
isDrawerOpen &&
|
||||
(isRoom
|
||||
? "buzz-huddle-app-surface-room-open"
|
||||
: "buzz-huddle-app-surface-open"),
|
||||
)}
|
||||
>
|
||||
<BuzzTheme.GradientLayer />
|
||||
{children}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</RemindMeLaterProvider>
|
||||
{isRoom || !isCompanionOpen ? (
|
||||
<div className="buzz-huddle-drawer-slot absolute inset-x-0 bottom-0 z-[2] h-(--buzz-huddle-drawer-height)">
|
||||
<AppHuddleBar
|
||||
mode={isRoom ? "room" : "main"}
|
||||
onOpenHuddleWindow={isRoom ? undefined : onCompanionOpen}
|
||||
onVisibilityChange={onVisibilityChange}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</RemindMeLaterProvider>
|
||||
</HuddleShortcutHandler>
|
||||
</HuddleProvider>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
markAllReadSources,
|
||||
activateDesktopNotificationTarget,
|
||||
createDesktopNotificationActivationQueue,
|
||||
shouldBounceForChannelNotification,
|
||||
} from "./AppShell.helpers.ts";
|
||||
|
||||
@@ -31,6 +33,177 @@ test("shouldBounceForChannelNotification_allowsBroadcastReplies", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("notification activation queue preserves click order", async () => {
|
||||
const calls = [];
|
||||
const resolvers = new Map();
|
||||
const queue = createDesktopNotificationActivationQueue((target) => {
|
||||
calls.push(`start:${target.channelId}`);
|
||||
return new Promise((resolve) => {
|
||||
resolvers.set(target.channelId, () => {
|
||||
calls.push(`finish:${target.channelId}`);
|
||||
resolve();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
queue.enqueue({ channelId: "first", eventId: null, kind: null });
|
||||
queue.enqueue({ channelId: "second", eventId: null, kind: null });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls, ["start:first"]);
|
||||
|
||||
resolvers.get("first")();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls, ["start:first", "finish:first", "start:second"]);
|
||||
|
||||
resolvers.get("second")();
|
||||
});
|
||||
|
||||
test("notification activation queue drops pending targets after cancellation", async () => {
|
||||
const calls = [];
|
||||
let resolveFirst;
|
||||
const queue = createDesktopNotificationActivationQueue((target) => {
|
||||
calls.push(target.channelId);
|
||||
if (target.channelId === "first") {
|
||||
return new Promise((resolve) => {
|
||||
resolveFirst = resolve;
|
||||
});
|
||||
}
|
||||
return Promise.resolve();
|
||||
});
|
||||
|
||||
queue.enqueue({ channelId: "first", eventId: null, kind: null });
|
||||
queue.enqueue({ channelId: "second", eventId: null, kind: null });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls, ["first"]);
|
||||
|
||||
queue.cancel();
|
||||
resolveFirst();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(calls, ["first"]);
|
||||
});
|
||||
|
||||
test("notification activation queue aborts an in-flight activation", async () => {
|
||||
let observedSignal;
|
||||
let resolveActivation;
|
||||
const queue = createDesktopNotificationActivationQueue((_target, signal) => {
|
||||
observedSignal = signal;
|
||||
return new Promise((resolve) => {
|
||||
resolveActivation = resolve;
|
||||
});
|
||||
});
|
||||
|
||||
queue.enqueue({ channelId: "first", eventId: null, kind: null });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.equal(observedSignal.aborted, false);
|
||||
|
||||
queue.cancel();
|
||||
assert.equal(observedSignal.aborted, true);
|
||||
resolveActivation();
|
||||
});
|
||||
|
||||
test("notification activation queue reports failures and continues", async () => {
|
||||
const calls = [];
|
||||
const errors = [];
|
||||
const queue = createDesktopNotificationActivationQueue(
|
||||
async (target) => {
|
||||
calls.push(target.channelId);
|
||||
if (target.channelId === "first") {
|
||||
throw new Error("navigation failed");
|
||||
}
|
||||
},
|
||||
(error) => errors.push(error),
|
||||
);
|
||||
|
||||
queue.enqueue({ channelId: "first", eventId: null, kind: null });
|
||||
queue.enqueue({ channelId: "second", eventId: null, kind: null });
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(calls, ["first", "second"]);
|
||||
assert.equal(errors.length, 1);
|
||||
assert.match(errors[0].message, /navigation failed/);
|
||||
});
|
||||
|
||||
test("notification activation starts routing before a hung reveal", async () => {
|
||||
const calls = [];
|
||||
let resolveNavigation;
|
||||
let navigationSettled = false;
|
||||
const activation = activateDesktopNotificationTarget(
|
||||
{
|
||||
channelId: "channel",
|
||||
eventId: "event",
|
||||
kind: 9,
|
||||
},
|
||||
{
|
||||
goChannel: async () => calls.push("channel"),
|
||||
goHome: async () => calls.push("home"),
|
||||
revealWindow: () => new Promise(() => {}),
|
||||
openSearchHit: (_hit, behavior) => {
|
||||
calls.push(`message:${String(behavior?.force)}`);
|
||||
return new Promise((resolve) => {
|
||||
resolveNavigation = () => {
|
||||
navigationSettled = true;
|
||||
resolve();
|
||||
};
|
||||
});
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ["message:true"]);
|
||||
resolveNavigation();
|
||||
await activation;
|
||||
assert.equal(navigationSettled, true);
|
||||
});
|
||||
|
||||
test("notification activation falls back to forced channel navigation", async () => {
|
||||
const calls = [];
|
||||
await activateDesktopNotificationTarget(
|
||||
{ channelId: "channel", eventId: null, kind: null },
|
||||
{
|
||||
goChannel: async (channelId, behavior) =>
|
||||
calls.push(`${channelId}:${String(behavior?.force)}`),
|
||||
goHome: async () => calls.push("home"),
|
||||
openSearchHit: async () => calls.push("message"),
|
||||
revealWindow: async () => calls.push("reveal"),
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ["channel:true", "reveal"]);
|
||||
});
|
||||
|
||||
test("notification activation ignores reveal rejection after routing starts", async () => {
|
||||
const calls = [];
|
||||
await activateDesktopNotificationTarget(
|
||||
{ channelId: "channel", eventId: null, kind: null },
|
||||
{
|
||||
goChannel: async () => calls.push("channel"),
|
||||
goHome: async () => calls.push("home"),
|
||||
openSearchHit: async () => calls.push("message"),
|
||||
revealWindow: async () => {
|
||||
calls.push("reveal");
|
||||
throw new Error("reveal failed");
|
||||
},
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ["channel", "reveal"]);
|
||||
});
|
||||
|
||||
test("notification activation without a channel opens home", async () => {
|
||||
const calls = [];
|
||||
await activateDesktopNotificationTarget(
|
||||
{ channelId: null, eventId: "event", kind: 9 },
|
||||
{
|
||||
goChannel: async () => calls.push("channel"),
|
||||
goHome: async () => calls.push("home"),
|
||||
openSearchHit: async () => calls.push("message"),
|
||||
revealWindow: async () => calls.push("reveal"),
|
||||
},
|
||||
);
|
||||
|
||||
assert.deepEqual(calls, ["home", "reveal"]);
|
||||
});
|
||||
|
||||
test("markAllReadSources clears Inbox overrides and active thread activity", () => {
|
||||
const calls = [];
|
||||
|
||||
|
||||
@@ -141,6 +141,79 @@ export function toSearchHit(
|
||||
};
|
||||
}
|
||||
|
||||
export function createDesktopNotificationActivationQueue(
|
||||
activate: (
|
||||
target: DesktopNotificationTarget,
|
||||
signal: AbortSignal,
|
||||
) => Promise<void>,
|
||||
onError?: (error: unknown) => void,
|
||||
): {
|
||||
cancel: () => void;
|
||||
enqueue: (target: DesktopNotificationTarget) => void;
|
||||
} {
|
||||
const controller = new AbortController();
|
||||
let pending = Promise.resolve();
|
||||
|
||||
return {
|
||||
cancel: () => {
|
||||
controller.abort();
|
||||
},
|
||||
enqueue: (target) => {
|
||||
// Preserve native click order when macOS drains multiple queued targets.
|
||||
// Contain failures so one rejected navigation cannot poison later clicks.
|
||||
pending = pending
|
||||
.then(() => {
|
||||
if (!controller.signal.aborted) {
|
||||
return activate(target, controller.signal);
|
||||
}
|
||||
})
|
||||
.catch((error) => {
|
||||
try {
|
||||
onError?.(error);
|
||||
} catch {
|
||||
// Reporting must not poison the activation queue either.
|
||||
}
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export async function activateDesktopNotificationTarget(
|
||||
target: DesktopNotificationTarget,
|
||||
actions: {
|
||||
goChannel: (
|
||||
channelId: string,
|
||||
options?: { force?: boolean },
|
||||
) => Promise<unknown>;
|
||||
goHome: () => Promise<unknown>;
|
||||
openSearchHit: (
|
||||
hit: SearchHit,
|
||||
behavior?: { force?: boolean; signal?: AbortSignal },
|
||||
) => Promise<unknown>;
|
||||
revealWindow: () => Promise<void>;
|
||||
},
|
||||
signal?: AbortSignal,
|
||||
): Promise<void> {
|
||||
if (signal?.aborted) {
|
||||
return;
|
||||
}
|
||||
|
||||
let navigation: Promise<unknown>;
|
||||
if (!target.channelId) {
|
||||
navigation = actions.goHome();
|
||||
} else {
|
||||
const anchor = toSearchHit(target);
|
||||
navigation = anchor
|
||||
? actions.openSearchHit(anchor, { force: true, signal })
|
||||
: actions.goChannel(target.channelId, { force: true });
|
||||
}
|
||||
|
||||
// Native activation already foregrounds the app on macOS. Other platforms
|
||||
// still get a best-effort reveal, but it must never gate click-through.
|
||||
void actions.revealWindow().catch(() => undefined);
|
||||
await navigation;
|
||||
}
|
||||
|
||||
export function deriveShellRoute(pathname: string): {
|
||||
selectedChannelId: string | null;
|
||||
selectedView: AppView;
|
||||
|
||||
@@ -8,6 +8,10 @@ import { AppShellOverlays, TerminalBootstrap } from "@/app/AppShellOverlays";
|
||||
import { AppShellChannelSurface } from "@/app/AppShellChannelSurface";
|
||||
import { AppHuddleShell } from "@/app/AppHuddleShell";
|
||||
import { AppTopChrome } from "@/app/AppTopChrome";
|
||||
import {
|
||||
type TerminalContextOverride,
|
||||
TerminalContextOverrideProvider,
|
||||
} from "@/app/TerminalContextOverrideContext";
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useBackForwardControls } from "@/app/navigation/useBackForwardControls";
|
||||
import { useCommunityNavigationTransitions } from "@/app/useCommunityNavigationTransitions";
|
||||
@@ -57,7 +61,8 @@ import {
|
||||
useUserStatusSubscription,
|
||||
} from "@/features/user-status/hooks";
|
||||
import { useCommunityEmojiLiveUpdates } from "@/features/custom-emoji/hooks";
|
||||
import { useArchiveSync } from "@/features/local-archive/archiveSyncManager";
|
||||
import { useArchiveSync } from "@/features/local-archive/useArchiveSync";
|
||||
import { useArchiveAgentMetricsBridge } from "@/features/local-archive/useArchiveAgentMetricsBridge";
|
||||
import { useObserverArchiveReconciliation } from "@/features/local-archive/useObserverArchiveSeed";
|
||||
import { useAgentMetricArchiveSeed } from "@/features/local-archive/useAgentMetricArchiveSeed";
|
||||
import { useProfileQuery } from "@/features/profile/hooks";
|
||||
@@ -90,12 +95,13 @@ import { useWebviewScrollBoundaryLock } from "@/shared/hooks/useWebviewScrollBou
|
||||
import { joinChannel } from "@/shared/api/tauri";
|
||||
import type { Channel, ChannelVisibility, SearchHit } from "@/shared/api/types";
|
||||
import { ChannelNavigationProvider } from "@/shared/context/ChannelNavigationContext";
|
||||
import { useMessageDeepLinks } from "@/shared/useMessageDeepLinks";
|
||||
import { useAppDeepLinks } from "@/shared/useAppDeepLinks";
|
||||
import { SidebarProvider } from "@/shared/ui/sidebar";
|
||||
import { RelayConnectionOverlay } from "@/app/RelayConnectionOverlay";
|
||||
import { useSidebarRelayConnectionCard } from "@/features/sidebar/ui/useSidebarRelayConnectionCard";
|
||||
import { AppShellTrayMenu } from "@/app/useAppShellTrayMenu";
|
||||
import { AppProfilePanelProvider } from "@/app/AppProfilePanelProvider";
|
||||
import { AppWorkflowEditorOverlayProvider } from "@/app/AppWorkflowEditorOverlayProvider";
|
||||
import { LazySettingsScreen } from "@/app/LazySettingsScreen";
|
||||
const EMPTY_CHANNELS: Channel[] = [];
|
||||
export function AppShell() {
|
||||
@@ -205,6 +211,9 @@ export function AppShell() {
|
||||
// useArchiveSync must wait for reconciliation, or listeners could open
|
||||
// before kind 24200 is guaranteed present in the subscription.
|
||||
useArchiveSync(observerReconciled);
|
||||
// The archive batch now persists in Rust, so the agent-metrics invalidation
|
||||
// signal arrives as a Tauri event rather than an in-process call.
|
||||
useArchiveAgentMetricsBridge();
|
||||
// Kind 44200 is relay-persisted (durable) and stays deferred: missed
|
||||
// startup frames can be replayed, so there's no ordering constraint.
|
||||
const deferredPubkey = startupReady ? identityQuery.data?.pubkey : undefined;
|
||||
@@ -312,6 +321,8 @@ export function AppShell() {
|
||||
selectedView,
|
||||
sidebarChannels,
|
||||
]);
|
||||
const [terminalContextOverride, setTerminalContextOverride] =
|
||||
React.useState<TerminalContextOverride | null>(null);
|
||||
const { activeChannel, terminalContext } = useTerminalContext({
|
||||
channelId: selectedChannelId,
|
||||
channels,
|
||||
@@ -319,6 +330,14 @@ export function AppShell() {
|
||||
pubkey: identityQuery.data?.pubkey,
|
||||
relayUrl: communitiesHook.activeCommunity?.relayUrl,
|
||||
});
|
||||
const effectiveTerminalContext = terminalContextOverride
|
||||
? {
|
||||
...terminalContext,
|
||||
channelId: terminalContextOverride.channelId,
|
||||
channelName: terminalContextOverride.channelName,
|
||||
threadId: null,
|
||||
}
|
||||
: terminalContext;
|
||||
const managedChannel = React.useMemo(() => {
|
||||
const targetChannelId = managedChannelId ?? selectedChannelId;
|
||||
return targetChannelId
|
||||
@@ -637,16 +656,17 @@ export function AppShell() {
|
||||
useAppShellLifecycleEffects({
|
||||
desktopBadgeEnabled: !isHuddleRoom,
|
||||
homeBadgeCountExcludingHighPriority,
|
||||
unreadChannelIds,
|
||||
topLevelUnreadChannelIds,
|
||||
unreadChannelNotificationCount,
|
||||
});
|
||||
// Dispatch `buzz://message` deep links only from the main window; the companion is dedicated to its active Huddle route.
|
||||
useMessageDeepLinks(!isHuddleRoom);
|
||||
// Dispatch `buzz://` deep links only from the main window; the companion is dedicated to its active Huddle route.
|
||||
useAppDeepLinks(!isHuddleRoom);
|
||||
const handleOpenCreateChannel = React.useCallback(
|
||||
() => setIsCreateChannelOpen(true),
|
||||
[],
|
||||
);
|
||||
useAppShellKeyboardShortcuts({
|
||||
activeChannelId: selectedView === "channel" ? selectedChannelId : null,
|
||||
canSearchCurrentChannel:
|
||||
selectedView === "channel" && Boolean(activeChannel),
|
||||
disabled: settingsOpen || isHuddleRoom,
|
||||
@@ -745,206 +765,222 @@ export function AppShell() {
|
||||
data-testid="app-sidebar-layer"
|
||||
>
|
||||
<AppProfilePanelProvider>
|
||||
{!settingsOpen && !isHuddleRoom ? (
|
||||
<AppTopChrome
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
hasCommunityRail={hasCommunityRail}
|
||||
onGoBack={goBack}
|
||||
onGoForward={goForward}
|
||||
/>
|
||||
) : null}
|
||||
{settingsOpen ? (
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<React.Suspense fallback={null}>
|
||||
<LazySettingsScreen
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
isUpdatingDesktopNotifications={
|
||||
notificationSettings.isUpdatingDesktopEnabled
|
||||
}
|
||||
notificationErrorMessage={
|
||||
notificationSettings.errorMessage
|
||||
}
|
||||
notificationPermission={notificationSettings.permission}
|
||||
notificationSettings={notificationSettings.settings}
|
||||
onClose={handleCloseSettings}
|
||||
onSectionChange={handleSettingsSectionChange}
|
||||
onSetDesktopNotificationsEnabled={
|
||||
notificationSettings.setDesktopEnabled
|
||||
}
|
||||
onSetHomeBadgeEnabled={
|
||||
notificationSettings.setHomeBadgeEnabled
|
||||
}
|
||||
onSetSlotAlertsEnabled={
|
||||
notificationSettings.setSlotAlertsEnabled
|
||||
}
|
||||
onSetNotifyWhileViewing={
|
||||
notificationSettings.setNotifyWhileViewing
|
||||
}
|
||||
onSetAllSlotAlertsEnabled={
|
||||
notificationSettings.setAllSlotAlertsEnabled
|
||||
}
|
||||
onSetSoundForSlot={notificationSettings.setSoundForSlot}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex min-h-0 flex-1 overflow-visible">
|
||||
{!isHuddleRoom ? (
|
||||
<AppSidebar
|
||||
activeCommunity={communitiesHook.activeCommunity}
|
||||
channels={sidebarChannels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
errorMessage={channelsErrorMessage}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
homeBadgeCount={homeBadgeCount + dueReminderBadge}
|
||||
addCommunityPrefill={addCommunityDialog.prefill}
|
||||
isAddCommunityOpen={addCommunityDialog.open}
|
||||
relayConnectionCard={relayConnectionCard}
|
||||
isCreatingChannel={createChannelMutation.isPending}
|
||||
isCreatingForum={createForumMutation.isPending}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
isCreateChannelOpen={isCreateChannelOpen}
|
||||
isHuddleCompanionOpen={isHuddleCompanionOpen}
|
||||
isPresencePending={presenceSession.isPending}
|
||||
onAddCommunity={(community) => {
|
||||
const id = communitiesHook.addCommunity({
|
||||
...community,
|
||||
pubkey:
|
||||
community.pubkey ?? identityQuery.data?.pubkey,
|
||||
});
|
||||
handleSwitchCommunity(id);
|
||||
}}
|
||||
onAddCommunityOpenChange={
|
||||
addCommunityDialog.onOpenChange
|
||||
}
|
||||
onNewMessage={goNewMessage}
|
||||
onBackgroundClick={requestFocusedThreadClose}
|
||||
onCreateChannelOpenChange={setIsCreateChannelOpen}
|
||||
onOpenAddCommunity={addCommunityDialog.openDialog}
|
||||
onSendFeedback={() => setIsSendFeedbackOpen(true)}
|
||||
onUpdateCommunity={communitiesHook.updateCommunity}
|
||||
onRemoveCommunity={handleRemoveCommunity}
|
||||
onSwitchCommunity={handleSwitchCommunity}
|
||||
onCreateAgent={() => requestOpenCreateAgent()}
|
||||
selfPresenceStatus={presenceSession.currentStatus}
|
||||
communities={communitiesHook.communities}
|
||||
onCreateChannel={handleCreateChannel}
|
||||
onCreateForum={handleCreateForum}
|
||||
onHideDm={handleHideDm}
|
||||
onHuddleEnded={handleHuddleEnded}
|
||||
onMarkAllChannelsRead={markAllChannelsRead}
|
||||
onMarkChannelRead={markChannelRead}
|
||||
onMarkChannelUnread={markChannelUnread}
|
||||
onBrowseChannels={handleOpenBrowseChannels}
|
||||
onOpenDm={async ({ pubkeys }) => {
|
||||
const directMessage =
|
||||
await openDmMutation.mutateAsync({
|
||||
pubkeys,
|
||||
<AppWorkflowEditorOverlayProvider>
|
||||
{!settingsOpen && !isHuddleRoom ? (
|
||||
<AppTopChrome
|
||||
canGoBack={canGoBack}
|
||||
canGoForward={canGoForward}
|
||||
hasCommunityRail={hasCommunityRail}
|
||||
onGoBack={goBack}
|
||||
onGoForward={goForward}
|
||||
/>
|
||||
) : null}
|
||||
{settingsOpen ? (
|
||||
<div className="flex min-h-0 flex-1 overflow-hidden">
|
||||
<React.Suspense fallback={null}>
|
||||
<LazySettingsScreen
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
isUpdatingDesktopNotifications={
|
||||
notificationSettings.isUpdatingDesktopEnabled
|
||||
}
|
||||
notificationErrorMessage={
|
||||
notificationSettings.errorMessage
|
||||
}
|
||||
notificationPermission={
|
||||
notificationSettings.permission
|
||||
}
|
||||
notificationSettings={notificationSettings.settings}
|
||||
onClose={handleCloseSettings}
|
||||
onSectionChange={handleSettingsSectionChange}
|
||||
onSetDesktopNotificationsEnabled={
|
||||
notificationSettings.setDesktopEnabled
|
||||
}
|
||||
onSetHomeBadgeEnabled={
|
||||
notificationSettings.setHomeBadgeEnabled
|
||||
}
|
||||
onSetSlotAlertsEnabled={
|
||||
notificationSettings.setSlotAlertsEnabled
|
||||
}
|
||||
onSetNotifyWhileViewing={
|
||||
notificationSettings.setNotifyWhileViewing
|
||||
}
|
||||
onSetAllSlotAlertsEnabled={
|
||||
notificationSettings.setAllSlotAlertsEnabled
|
||||
}
|
||||
onSetSoundForSlot={
|
||||
notificationSettings.setSoundForSlot
|
||||
}
|
||||
section={settingsSection}
|
||||
/>
|
||||
</React.Suspense>
|
||||
</div>
|
||||
) : (
|
||||
<div className="relative flex min-h-0 flex-1 overflow-visible">
|
||||
{!isHuddleRoom ? (
|
||||
<AppSidebar
|
||||
activeCommunity={communitiesHook.activeCommunity}
|
||||
channels={sidebarChannels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
errorMessage={channelsErrorMessage}
|
||||
fallbackDisplayName={identityQuery.data?.displayName}
|
||||
homeBadgeCount={homeBadgeCount + dueReminderBadge}
|
||||
addCommunityPrefill={addCommunityDialog.prefill}
|
||||
isAddCommunityOpen={addCommunityDialog.open}
|
||||
relayConnectionCard={relayConnectionCard}
|
||||
isCreatingChannel={createChannelMutation.isPending}
|
||||
isCreatingForum={createForumMutation.isPending}
|
||||
isLoading={channelsQuery.isLoading}
|
||||
isCreateChannelOpen={isCreateChannelOpen}
|
||||
isHuddleCompanionOpen={isHuddleCompanionOpen}
|
||||
isPresencePending={presenceSession.isPending}
|
||||
onAddCommunity={(community) => {
|
||||
const id = communitiesHook.addCommunity({
|
||||
...community,
|
||||
pubkey:
|
||||
community.pubkey ?? identityQuery.data?.pubkey,
|
||||
});
|
||||
await goChannel(directMessage.id);
|
||||
}}
|
||||
onSelectAgents={() => void goAgents()}
|
||||
onSelectChannel={handleSidebarChannelSelect}
|
||||
onOpenSearchResult={handleOpenSearchResult}
|
||||
searchChannels={channels}
|
||||
searchFocusRequests={[
|
||||
searchFocusRequest,
|
||||
scopeSearchFocusRequest,
|
||||
]}
|
||||
onSelectHome={() => void goHome()}
|
||||
onSelectProjects={() => void goProjects()}
|
||||
onSelectPulse={() => void goPulse()}
|
||||
onSelectSettings={handleOpenSettings}
|
||||
onSelectWorkflows={() => void goWorkflows()}
|
||||
onSetPresenceStatus={(status) =>
|
||||
presenceSession.setStatus(status)
|
||||
}
|
||||
onSetUserStatus={(text, emoji) =>
|
||||
setUserStatusMutation.mutate({ text, emoji })
|
||||
}
|
||||
onClearUserStatus={() =>
|
||||
setUserStatusMutation.mutate({
|
||||
text: "",
|
||||
emoji: "",
|
||||
})
|
||||
}
|
||||
profile={profileQuery.data}
|
||||
selfUserStatus={
|
||||
deferredPubkey
|
||||
? (selfStatusQuery.data?.[
|
||||
deferredPubkey.toLowerCase()
|
||||
] ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
selectedChannelId={selectedChannelId}
|
||||
selectedView={selectedView}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
previewActivityChannelIds={unreadThreadChannelIds}
|
||||
unreadChannelCounts={unreadChannelCounts}
|
||||
mutedChannelIds={mutedChannelIds}
|
||||
onMuteChannel={muteChannel}
|
||||
onUnmuteChannel={unmuteChannel}
|
||||
starredChannelIds={starredChannelIds}
|
||||
onStarChannel={starChannel}
|
||||
onUnstarChannel={unstarChannel}
|
||||
/>
|
||||
) : null}
|
||||
<AppShellChannelSurface
|
||||
isHuddleRoom={isHuddleRoom}
|
||||
isHuddleRoomStarting={isHuddleRoomStarting}
|
||||
mainInsetRef={mainInsetRef}
|
||||
terminal={<TerminalBootstrap {...terminalContext} />}
|
||||
>
|
||||
<Outlet />
|
||||
</AppShellChannelSurface>
|
||||
{!isHuddleRoom ? (
|
||||
<RelayConnectionOverlay
|
||||
card={relayConnectionCard}
|
||||
errorMessage={channelsErrorMessage}
|
||||
hasCommunityRail={hasCommunityRail}
|
||||
isHuddleDrawerOpen={isHuddleDrawerOpen}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<RequestedAgentCreateDialogs />
|
||||
<AgentManagementDialogs />
|
||||
<AppShellOverlays
|
||||
activeChannel={managedChannel}
|
||||
browseDialogType={browseDialogType}
|
||||
channels={channels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
isChannelManagementOpen={isChannelManagementOpen}
|
||||
isCreatingBrowseChannel={
|
||||
createChannelMutation.isPending ||
|
||||
createForumMutation.isPending
|
||||
}
|
||||
onBrowseChannelJoin={handleBrowseChannelJoin}
|
||||
onBrowseChannelCreate={handleBrowseChannelCreate}
|
||||
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
|
||||
onChannelManagementOpenChange={(open) => {
|
||||
setIsChannelManagementOpen(open);
|
||||
if (!open) {
|
||||
setManagedChannelId(null);
|
||||
handleSwitchCommunity(id);
|
||||
}}
|
||||
onAddCommunityOpenChange={
|
||||
addCommunityDialog.onOpenChange
|
||||
}
|
||||
onNewMessage={goNewMessage}
|
||||
onBackgroundClick={requestFocusedThreadClose}
|
||||
onCreateChannelOpenChange={setIsCreateChannelOpen}
|
||||
onOpenAddCommunity={addCommunityDialog.openDialog}
|
||||
onSendFeedback={() => setIsSendFeedbackOpen(true)}
|
||||
onUpdateCommunity={communitiesHook.updateCommunity}
|
||||
onRemoveCommunity={handleRemoveCommunity}
|
||||
onSwitchCommunity={handleSwitchCommunity}
|
||||
onCreateAgent={() => requestOpenCreateAgent()}
|
||||
selfPresenceStatus={presenceSession.currentStatus}
|
||||
communities={communitiesHook.communities}
|
||||
onCreateChannel={handleCreateChannel}
|
||||
onCreateForum={handleCreateForum}
|
||||
onHideDm={handleHideDm}
|
||||
onHuddleEnded={handleHuddleEnded}
|
||||
onMarkAllChannelsRead={markAllChannelsRead}
|
||||
onMarkChannelRead={markChannelRead}
|
||||
onMarkChannelUnread={markChannelUnread}
|
||||
onBrowseChannels={handleOpenBrowseChannels}
|
||||
onOpenDm={async ({ pubkeys }) => {
|
||||
const directMessage =
|
||||
await openDmMutation.mutateAsync({
|
||||
pubkeys,
|
||||
});
|
||||
await goChannel(directMessage.id);
|
||||
}}
|
||||
onSelectAgents={() => void goAgents()}
|
||||
onSelectChannel={handleSidebarChannelSelect}
|
||||
onOpenSearchResult={handleOpenSearchResult}
|
||||
searchChannels={channels}
|
||||
searchFocusRequests={[
|
||||
searchFocusRequest,
|
||||
scopeSearchFocusRequest,
|
||||
]}
|
||||
onSelectHome={() => void goHome()}
|
||||
onSelectProjects={() => void goProjects()}
|
||||
onSelectPulse={() => void goPulse()}
|
||||
onSelectSettings={handleOpenSettings}
|
||||
onSelectWorkflows={() => void goWorkflows()}
|
||||
onSetPresenceStatus={(status) =>
|
||||
presenceSession.setStatus(status)
|
||||
}
|
||||
onSetUserStatus={(text, emoji) =>
|
||||
setUserStatusMutation.mutate({ text, emoji })
|
||||
}
|
||||
onClearUserStatus={() =>
|
||||
setUserStatusMutation.mutate({
|
||||
text: "",
|
||||
emoji: "",
|
||||
})
|
||||
}
|
||||
profile={profileQuery.data}
|
||||
projectsOverviewActive={
|
||||
location.pathname === "/projects"
|
||||
}
|
||||
selfUserStatus={
|
||||
deferredPubkey
|
||||
? (selfStatusQuery.data?.[
|
||||
deferredPubkey.toLowerCase()
|
||||
] ?? undefined)
|
||||
: undefined
|
||||
}
|
||||
selectedChannelId={selectedChannelId}
|
||||
selectedView={selectedView}
|
||||
unreadChannelIds={unreadChannelIds}
|
||||
previewActivityChannelIds={unreadThreadChannelIds}
|
||||
unreadChannelCounts={unreadChannelCounts}
|
||||
mutedChannelIds={mutedChannelIds}
|
||||
onMuteChannel={muteChannel}
|
||||
onUnmuteChannel={unmuteChannel}
|
||||
starredChannelIds={starredChannelIds}
|
||||
onStarChannel={starChannel}
|
||||
onUnstarChannel={unstarChannel}
|
||||
/>
|
||||
) : null}
|
||||
<TerminalContextOverrideProvider
|
||||
onChange={setTerminalContextOverride}
|
||||
>
|
||||
<AppShellChannelSurface
|
||||
hasCommunityRail={hasCommunityRail}
|
||||
isHuddleRoom={isHuddleRoom}
|
||||
isHuddleRoomStarting={isHuddleRoomStarting}
|
||||
mainInsetRef={mainInsetRef}
|
||||
terminal={
|
||||
<TerminalBootstrap {...effectiveTerminalContext} />
|
||||
}
|
||||
>
|
||||
<Outlet />
|
||||
</AppShellChannelSurface>
|
||||
</TerminalContextOverrideProvider>
|
||||
{!isHuddleRoom ? (
|
||||
<RelayConnectionOverlay
|
||||
card={relayConnectionCard}
|
||||
errorMessage={channelsErrorMessage}
|
||||
hasCommunityRail={hasCommunityRail}
|
||||
isHuddleDrawerOpen={isHuddleDrawerOpen}
|
||||
/>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
<RequestedAgentCreateDialogs />
|
||||
<AgentManagementDialogs />
|
||||
<AppShellOverlays
|
||||
activeChannel={managedChannel}
|
||||
browseDialogType={browseDialogType}
|
||||
channels={channels}
|
||||
currentPubkey={identityQuery.data?.pubkey}
|
||||
isChannelManagementOpen={isChannelManagementOpen}
|
||||
isCreatingBrowseChannel={
|
||||
createChannelMutation.isPending ||
|
||||
createForumMutation.isPending
|
||||
}
|
||||
}}
|
||||
onDeleteActiveChannel={() => {
|
||||
setIsChannelManagementOpen(false);
|
||||
setManagedChannelId(null);
|
||||
void goHome({ replace: true });
|
||||
}}
|
||||
onSelectChannel={(channelId) => {
|
||||
void goChannel(channelId);
|
||||
}}
|
||||
relayUrl={communitiesHook.activeCommunity?.relayUrl}
|
||||
/>
|
||||
<SendFeedbackController
|
||||
onOpenChange={setIsSendFeedbackOpen}
|
||||
open={isSendFeedbackOpen}
|
||||
/>
|
||||
onBrowseChannelJoin={handleBrowseChannelJoin}
|
||||
onBrowseChannelCreate={handleBrowseChannelCreate}
|
||||
onBrowseDialogOpenChange={handleBrowseDialogOpenChange}
|
||||
onChannelManagementOpenChange={(open) => {
|
||||
setIsChannelManagementOpen(open);
|
||||
if (!open) {
|
||||
setManagedChannelId(null);
|
||||
}
|
||||
}}
|
||||
onDeleteActiveChannel={() => {
|
||||
setIsChannelManagementOpen(false);
|
||||
setManagedChannelId(null);
|
||||
void goHome({ replace: true });
|
||||
}}
|
||||
onSelectChannel={(channelId) => {
|
||||
void goChannel(channelId);
|
||||
}}
|
||||
relayUrl={communitiesHook.activeCommunity?.relayUrl}
|
||||
/>
|
||||
<SendFeedbackController
|
||||
onOpenChange={setIsSendFeedbackOpen}
|
||||
open={isSendFeedbackOpen}
|
||||
/>
|
||||
</AppWorkflowEditorOverlayProvider>
|
||||
</AppProfilePanelProvider>
|
||||
</SidebarProvider>
|
||||
</AppHuddleShell>
|
||||
|
||||
@@ -4,10 +4,11 @@ import { HuddleRoomHeader, HuddleStartingView } from "@/features/huddle";
|
||||
import { MainInsetProvider } from "@/shared/layout/MainInsetContext";
|
||||
import { chromeCssVarDefaults } from "@/shared/layout/chromeLayout";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { SidebarInset } from "@/shared/ui/sidebar";
|
||||
import { SidebarInset, useSidebar } from "@/shared/ui/sidebar";
|
||||
|
||||
type AppShellChannelSurfaceProps = {
|
||||
children: React.ReactNode;
|
||||
hasCommunityRail: boolean;
|
||||
isHuddleRoom: boolean;
|
||||
isHuddleRoomStarting: boolean;
|
||||
mainInsetRef: React.RefObject<HTMLElement | null>;
|
||||
@@ -16,11 +17,18 @@ type AppShellChannelSurfaceProps = {
|
||||
|
||||
export function AppShellChannelSurface({
|
||||
children,
|
||||
hasCommunityRail,
|
||||
isHuddleRoom,
|
||||
isHuddleRoomStarting,
|
||||
mainInsetRef,
|
||||
terminal,
|
||||
}: AppShellChannelSurfaceProps) {
|
||||
const { isMobile, openMobile, state: sidebarState } = useSidebar();
|
||||
const hasCollapsedSidebarGutter =
|
||||
!isHuddleRoom &&
|
||||
!hasCommunityRail &&
|
||||
(isMobile ? !openMobile : sidebarState === "collapsed");
|
||||
|
||||
return (
|
||||
<MainInsetProvider mainInsetRef={mainInsetRef}>
|
||||
<SidebarInset
|
||||
@@ -28,6 +36,7 @@ export function AppShellChannelSurface({
|
||||
className={cn(
|
||||
"isolate z-0 min-h-0 min-w-0 overflow-hidden",
|
||||
isHuddleRoom ? "bg-background" : "bg-sidebar",
|
||||
hasCollapsedSidebarGutter && "pl-2",
|
||||
)}
|
||||
data-buzz-content-surface={isHuddleRoom ? true : undefined}
|
||||
data-buzz-content-unframed={isHuddleRoom ? true : undefined}
|
||||
@@ -35,6 +44,12 @@ export function AppShellChannelSurface({
|
||||
data-buzz-shadow-viewport
|
||||
style={chromeCssVarDefaults as React.CSSProperties}
|
||||
>
|
||||
{hasCollapsedSidebarGutter ? (
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 w-2 bg-sidebar"
|
||||
data-collapsed-content-gutter
|
||||
/>
|
||||
) : null}
|
||||
{isHuddleRoom && !isHuddleRoomStarting ? <HuddleRoomHeader /> : null}
|
||||
<BuzzTheme.ContentSurface terminal={terminal} unframed={isHuddleRoom}>
|
||||
{isHuddleRoomStarting ? <HuddleStartingView /> : children}
|
||||
|
||||
@@ -4,6 +4,10 @@ import * as React from "react";
|
||||
import type { Channel } from "@/shared/api/types";
|
||||
import type { CreateChannelInput } from "@/features/sidebar/lib/useCreateChannelForm";
|
||||
import { useDeferredModalOpen } from "@/shared/ui/deferredModalOpen";
|
||||
import {
|
||||
mergeOpenChannelDirectory,
|
||||
useOpenChannelDirectoryQuery,
|
||||
} from "@/features/channels/openChannelDirectory";
|
||||
|
||||
const ChannelBrowserDialog = React.lazy(async () => {
|
||||
const module = await import("@/features/channels/ui/ChannelBrowserDialog");
|
||||
@@ -76,12 +80,24 @@ export function AppShellOverlays({
|
||||
|
||||
const renderedBrowseDialogType = visibleBrowseDialogType ?? browseDialogType;
|
||||
|
||||
// The channel browser is the only overlay that shows non-member open
|
||||
// channels, so it — not the 60s poll — pays for the all-open directory scan,
|
||||
// and only while it is open. Merge the superset over the member list so a
|
||||
// just-joined or optimistic channel keeps its live state.
|
||||
const openDirectoryQuery = useOpenChannelDirectoryQuery({
|
||||
enabled: browseDialogType !== null,
|
||||
});
|
||||
const browserChannels = React.useMemo(
|
||||
() => mergeOpenChannelDirectory(channels, openDirectoryQuery.data),
|
||||
[channels, openDirectoryQuery.data],
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{browseDialogType !== null ? (
|
||||
<React.Suspense fallback={null}>
|
||||
<ChannelBrowserDialog
|
||||
channels={channels}
|
||||
channels={browserChannels}
|
||||
channelTypeFilter={renderedBrowseDialogType ?? browseDialogType}
|
||||
isCreatingChannel={isCreatingBrowseChannel}
|
||||
onCreateChannel={onBrowseChannelCreate}
|
||||
|
||||
@@ -1,14 +1,10 @@
|
||||
import * as React from "react";
|
||||
import {
|
||||
ChevronLeft,
|
||||
ChevronRight,
|
||||
PanelLeftClose,
|
||||
PanelLeftOpen,
|
||||
} from "lucide-react";
|
||||
import { ChevronLeft, ChevronRight } from "lucide-react";
|
||||
|
||||
import { isMacPlatform } from "@/shared/lib/platform";
|
||||
import { useIsFullscreen } from "@/shared/lib/useIsFullscreen";
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { DrawerPanelIcon } from "@/shared/ui/DrawerPanelIcon";
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
import { topChromeBackdrop } from "@/shared/layout/chromeLayout";
|
||||
import { useOptionalSidebar } from "@/shared/ui/sidebar";
|
||||
@@ -26,7 +22,7 @@ type AppTopChromeProps = {
|
||||
// the row must not grow or shrink with the rem scale. Deliberate exception
|
||||
// to the rem-first rule.
|
||||
const TOP_CHROME_ICON_BUTTON_CLASS =
|
||||
"h-[28px] w-[28px] rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-[16px]";
|
||||
"h-[28px] w-[28px] rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground";
|
||||
const HISTORY_ICON_BUTTON_CLASS =
|
||||
"h-[28px] w-[24px] rounded-[4px] text-sidebar-foreground/65 hover:bg-sidebar-accent hover:text-sidebar-accent-foreground [&_svg]:size-[16px]";
|
||||
|
||||
@@ -50,7 +46,7 @@ function TopChromeSidebarTrigger() {
|
||||
type="button"
|
||||
variant="ghost"
|
||||
>
|
||||
{sidebar?.open ? <PanelLeftClose /> : <PanelLeftOpen />}
|
||||
<DrawerPanelIcon side={sidebar?.open ? "left" : "right"} />
|
||||
<span className="sr-only">Toggle Sidebar</span>
|
||||
</Button>
|
||||
);
|
||||
@@ -82,6 +78,29 @@ export function AppTopChrome({
|
||||
: "pl-3";
|
||||
const navRowAlignmentClass = macChrome ? "translate-y-[3px]" : null;
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const topChrome = topChromeRef.current;
|
||||
const portalTarget = topChrome?.querySelector<HTMLElement>(
|
||||
"#app-top-chrome-content",
|
||||
);
|
||||
if (!topChrome || !portalTarget) return;
|
||||
|
||||
const updateCenterOffset = () => {
|
||||
const portalBounds = portalTarget.getBoundingClientRect();
|
||||
const portalCenter = portalBounds.left + portalBounds.width / 2;
|
||||
topChrome.style.setProperty(
|
||||
"--app-top-chrome-center-offset",
|
||||
`${window.innerWidth / 2 - portalCenter}px`,
|
||||
);
|
||||
};
|
||||
|
||||
updateCenterOffset();
|
||||
const observer = new ResizeObserver(updateCenterOffset);
|
||||
observer.observe(topChrome);
|
||||
observer.observe(portalTarget);
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
React.useEffect(() => {
|
||||
const topChrome = topChromeRef.current;
|
||||
if (!topChrome) {
|
||||
@@ -105,6 +124,11 @@ export function AppTopChrome({
|
||||
)}
|
||||
data-tauri-drag-region
|
||||
data-testid="app-top-chrome"
|
||||
style={
|
||||
{
|
||||
"--app-top-chrome-center-offset": "0px",
|
||||
} as React.CSSProperties
|
||||
}
|
||||
>
|
||||
<div className={cn("flex items-center gap-0.5", navRowAlignmentClass)}>
|
||||
<TopChromeSidebarTrigger />
|
||||
@@ -131,6 +155,11 @@ export function AppTopChrome({
|
||||
<ChevronRight />
|
||||
</Button>
|
||||
</div>
|
||||
<div
|
||||
className={cn("flex min-w-0 flex-1 items-center", navRowAlignmentClass)}
|
||||
data-tauri-drag-region
|
||||
id="app-top-chrome-content"
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
18
desktop/src/app/AppTopChromePortal.tsx
Normal file
18
desktop/src/app/AppTopChromePortal.tsx
Normal file
@@ -0,0 +1,18 @@
|
||||
import * as React from "react";
|
||||
import { createPortal } from "react-dom";
|
||||
|
||||
const APP_TOP_CHROME_CONTENT_ID = "app-top-chrome-content";
|
||||
|
||||
export function AppTopChromePortal({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [target, setTarget] = React.useState<HTMLElement | null>(null);
|
||||
|
||||
React.useEffect(() => {
|
||||
setTarget(document.getElementById(APP_TOP_CHROME_CONTENT_ID));
|
||||
}, []);
|
||||
|
||||
return target ? createPortal(children, target) : null;
|
||||
}
|
||||
174
desktop/src/app/AppWorkflowEditorOverlayProvider.tsx
Normal file
174
desktop/src/app/AppWorkflowEditorOverlayProvider.tsx
Normal file
@@ -0,0 +1,174 @@
|
||||
import * as React from "react";
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
import { useLocation } from "@tanstack/react-router";
|
||||
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import { WorkflowDeleteDialog } from "@/features/workflows/ui/WorkflowDeleteDialog";
|
||||
import {
|
||||
WorkflowEditorHost,
|
||||
type WorkflowEditorTarget,
|
||||
} from "@/features/workflows/ui/WorkflowEditorHost";
|
||||
import type { WorkflowEditorPane } from "@/features/workflows/ui/workflowEditorPane";
|
||||
import { deleteWorkflow, triggerWorkflow } from "@/shared/api/tauriWorkflows";
|
||||
import type { Workflow } from "@/shared/api/types";
|
||||
import { WorkflowEditorOverlayProvider } from "@/shared/context/WorkflowEditorOverlayContext";
|
||||
|
||||
const INITIAL_PANE: WorkflowEditorPane = { type: "trigger" };
|
||||
|
||||
/** Rebuilds a target with a new pane without widening its discriminant. */
|
||||
function withPane(
|
||||
target: WorkflowEditorTarget,
|
||||
pane: WorkflowEditorPane,
|
||||
): WorkflowEditorTarget {
|
||||
return target.mode === "create"
|
||||
? { initialChannelId: target.initialChannelId, mode: "create", pane }
|
||||
: { mode: target.mode, pane, workflowId: target.workflowId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Hosts the shared workflow editor as an overlay owned by the app shell, so
|
||||
* surfaces like channel settings can open a workflow without navigating away
|
||||
* from the channel. The Workflows route keeps its own URL-addressable host —
|
||||
* both render the same editor.
|
||||
*/
|
||||
export function AppWorkflowEditorOverlayProvider({
|
||||
children,
|
||||
}: Readonly<{ children: React.ReactNode }>) {
|
||||
const queryClient = useQueryClient();
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const memberChannels = React.useMemo(
|
||||
() => (channelsQuery.data ?? []).filter((channel) => channel.isMember),
|
||||
[channelsQuery.data],
|
||||
);
|
||||
|
||||
const [editor, setEditor] = React.useState<WorkflowEditorTarget | null>(null);
|
||||
const [workflowHint, setWorkflowHint] = React.useState<Workflow | undefined>(
|
||||
undefined,
|
||||
);
|
||||
const [deleteTarget, setDeleteTarget] = React.useState<Workflow | null>(null);
|
||||
|
||||
const handleOpenWorkflow = React.useCallback(
|
||||
(workflowId: string, workflow?: Workflow) => {
|
||||
setWorkflowHint(workflow);
|
||||
setEditor({ mode: "detail", pane: INITIAL_PANE, workflowId });
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleOpenNewWorkflow = React.useCallback((channelId?: string) => {
|
||||
setWorkflowHint(undefined);
|
||||
setEditor({
|
||||
initialChannelId: channelId,
|
||||
mode: "create",
|
||||
pane: INITIAL_PANE,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const closeEditor = React.useCallback(() => {
|
||||
setEditor(null);
|
||||
setWorkflowHint(undefined);
|
||||
}, []);
|
||||
|
||||
// This editor belongs to the surface that opened it. If the route leaves that
|
||||
// surface anyway, drop it rather than trailing the modal onto the next screen.
|
||||
// The editor's own dirty-exit guard runs first, so unsaved work still prompts.
|
||||
const { pathname } = useLocation();
|
||||
const lastPathnameRef = React.useRef(pathname);
|
||||
React.useEffect(() => {
|
||||
if (lastPathnameRef.current === pathname) return;
|
||||
lastPathnameRef.current = pathname;
|
||||
closeEditor();
|
||||
}, [closeEditor, pathname]);
|
||||
|
||||
const handleEditorPaneChange = React.useCallback(
|
||||
(pane: WorkflowEditorPane) => {
|
||||
setEditor((current) => (current ? withPane(current, pane) : current));
|
||||
},
|
||||
[],
|
||||
);
|
||||
|
||||
const handleEditWorkflow = React.useCallback((workflowId: string) => {
|
||||
setEditor({ mode: "edit", pane: INITIAL_PANE, workflowId });
|
||||
}, []);
|
||||
|
||||
const handleDuplicateWorkflow = React.useCallback((workflowId: string) => {
|
||||
setEditor({ mode: "duplicate", pane: INITIAL_PANE, workflowId });
|
||||
}, []);
|
||||
|
||||
const triggerMutation = useMutation({
|
||||
mutationFn: (workflowId: string) => triggerWorkflow(workflowId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) => query.queryKey[0] === "workflow-runs",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const deleteMutation = useMutation({
|
||||
mutationFn: (workflowId: string) => deleteWorkflow(workflowId),
|
||||
onSuccess: () => {
|
||||
void queryClient.invalidateQueries({
|
||||
predicate: (query) =>
|
||||
query.queryKey[0] === "workflows" ||
|
||||
query.queryKey[0] === "workflows-all",
|
||||
});
|
||||
},
|
||||
});
|
||||
|
||||
const triggerOne = triggerMutation.mutate;
|
||||
const handleTriggerWorkflow = React.useCallback(
|
||||
(workflowId: string) => triggerOne(workflowId),
|
||||
[triggerOne],
|
||||
);
|
||||
|
||||
const deleteOne = deleteMutation.mutateAsync;
|
||||
const handleConfirmDelete = React.useCallback(
|
||||
async (workflow: Workflow) => {
|
||||
try {
|
||||
await deleteOne(workflow.id);
|
||||
setDeleteTarget(null);
|
||||
closeEditor();
|
||||
} catch {
|
||||
// React Query stores the error; keep the confirmation and editor open.
|
||||
}
|
||||
},
|
||||
[closeEditor, deleteOne],
|
||||
);
|
||||
|
||||
return (
|
||||
<WorkflowEditorOverlayProvider
|
||||
onOpenNewWorkflow={handleOpenNewWorkflow}
|
||||
onOpenWorkflow={handleOpenWorkflow}
|
||||
>
|
||||
{children}
|
||||
<WorkflowEditorHost
|
||||
channels={memberChannels}
|
||||
editor={editor}
|
||||
onClose={closeEditor}
|
||||
onDeleteWorkflow={setDeleteTarget}
|
||||
onDuplicateWorkflow={handleDuplicateWorkflow}
|
||||
onEditWorkflow={handleEditWorkflow}
|
||||
onEditorPaneChange={handleEditorPaneChange}
|
||||
onTriggerWorkflow={handleTriggerWorkflow}
|
||||
workflowHint={workflowHint}
|
||||
/>
|
||||
<WorkflowDeleteDialog
|
||||
error={
|
||||
deleteMutation.error instanceof Error
|
||||
? deleteMutation.error.message
|
||||
: null
|
||||
}
|
||||
isPending={deleteMutation.isPending}
|
||||
onConfirm={handleConfirmDelete}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
deleteMutation.reset();
|
||||
setDeleteTarget(null);
|
||||
}
|
||||
}}
|
||||
open={deleteTarget !== null}
|
||||
workflow={deleteTarget}
|
||||
/>
|
||||
</WorkflowEditorOverlayProvider>
|
||||
);
|
||||
}
|
||||
38
desktop/src/app/TerminalContextOverrideContext.tsx
Normal file
38
desktop/src/app/TerminalContextOverrideContext.tsx
Normal file
@@ -0,0 +1,38 @@
|
||||
import * as React from "react";
|
||||
|
||||
export type TerminalContextOverride = {
|
||||
channelId: string;
|
||||
channelName: string;
|
||||
};
|
||||
|
||||
const TerminalContextOverrideContext = React.createContext<React.Dispatch<
|
||||
React.SetStateAction<TerminalContextOverride | null>
|
||||
> | null>(null);
|
||||
|
||||
export function TerminalContextOverrideProvider({
|
||||
children,
|
||||
onChange,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
onChange: React.Dispatch<
|
||||
React.SetStateAction<TerminalContextOverride | null>
|
||||
>;
|
||||
}) {
|
||||
return (
|
||||
<TerminalContextOverrideContext.Provider value={onChange}>
|
||||
{children}
|
||||
</TerminalContextOverrideContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useTerminalContextOverride(
|
||||
context: TerminalContextOverride | null,
|
||||
) {
|
||||
const setOverride = React.useContext(TerminalContextOverrideContext);
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!setOverride) return;
|
||||
setOverride(context);
|
||||
return () => setOverride(null);
|
||||
}, [context, setOverride]);
|
||||
}
|
||||
100
desktop/src/app/navigation/navigationGuard.test.mjs
Normal file
100
desktop/src/app/navigation/navigationGuard.test.mjs
Normal file
@@ -0,0 +1,100 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
const target = {
|
||||
kind: "channel-message",
|
||||
channelId: "general",
|
||||
messageId: "message-a",
|
||||
threadRootId: "thread-a",
|
||||
};
|
||||
|
||||
const { allowNavigation, registerNavigationGuard, traverseHistory } =
|
||||
await import("./navigationGuard.ts");
|
||||
|
||||
test("all navigation consults the registered boundary guard", () => {
|
||||
let received;
|
||||
const unregister = registerNavigationGuard((nextTarget) => {
|
||||
received = nextTarget;
|
||||
return false;
|
||||
});
|
||||
|
||||
assert.equal(allowNavigation(target), false);
|
||||
assert.deepEqual(received, target);
|
||||
unregister();
|
||||
assert.equal(allowNavigation(target), true);
|
||||
});
|
||||
|
||||
test("guarded history traversal blocks before mutating history", () => {
|
||||
let received;
|
||||
let backCalls = 0;
|
||||
const unregister = registerNavigationGuard((nextTarget) => {
|
||||
received = nextTarget;
|
||||
return false;
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
traverseHistory(
|
||||
{
|
||||
back: () => {
|
||||
backCalls += 1;
|
||||
},
|
||||
forward: () => {},
|
||||
},
|
||||
"back",
|
||||
),
|
||||
false,
|
||||
);
|
||||
assert.deepEqual(received, { kind: "history", direction: "back" });
|
||||
assert.equal(backCalls, 0);
|
||||
unregister();
|
||||
});
|
||||
|
||||
test("guarded history traversal invokes the selected direction when allowed", () => {
|
||||
let forwardCalls = 0;
|
||||
|
||||
assert.equal(
|
||||
traverseHistory(
|
||||
{
|
||||
back: () => {},
|
||||
forward: () => {
|
||||
forwardCalls += 1;
|
||||
},
|
||||
},
|
||||
"forward",
|
||||
),
|
||||
true,
|
||||
);
|
||||
assert.equal(forwardCalls, 1);
|
||||
});
|
||||
|
||||
test("unregistering the newer guard restores the prior live guard", () => {
|
||||
const unregisterFirst = registerNavigationGuard(() => false);
|
||||
const unregisterSecond = registerNavigationGuard(() => true);
|
||||
|
||||
assert.equal(allowNavigation(target), true);
|
||||
unregisterSecond();
|
||||
assert.equal(allowNavigation(target), false);
|
||||
unregisterFirst();
|
||||
assert.equal(allowNavigation(target), true);
|
||||
});
|
||||
|
||||
test("stale cleanup cannot unregister a newer guard", () => {
|
||||
const unregisterFirst = registerNavigationGuard(() => false);
|
||||
const unregisterSecond = registerNavigationGuard(() => true);
|
||||
|
||||
unregisterFirst();
|
||||
assert.equal(allowNavigation(target), true);
|
||||
unregisterSecond();
|
||||
assert.equal(allowNavigation(target), true);
|
||||
});
|
||||
|
||||
test("duplicate callback registrations clean up by registration identity", () => {
|
||||
const sharedGuard = () => false;
|
||||
const unregisterFirst = registerNavigationGuard(sharedGuard);
|
||||
const unregisterSecond = registerNavigationGuard(sharedGuard);
|
||||
|
||||
unregisterFirst();
|
||||
assert.equal(allowNavigation(target), false);
|
||||
unregisterSecond();
|
||||
assert.equal(allowNavigation(target), true);
|
||||
});
|
||||
54
desktop/src/app/navigation/navigationGuard.ts
Normal file
54
desktop/src/app/navigation/navigationGuard.ts
Normal file
@@ -0,0 +1,54 @@
|
||||
export type GuardedNavigation =
|
||||
| {
|
||||
kind: "history";
|
||||
direction: "back" | "forward";
|
||||
}
|
||||
| {
|
||||
kind: "route";
|
||||
href: string;
|
||||
}
|
||||
| {
|
||||
kind: "channel-message";
|
||||
channelId: string;
|
||||
messageId: string;
|
||||
threadRootId: string | null;
|
||||
}
|
||||
| {
|
||||
kind: "forum-post";
|
||||
channelId: string;
|
||||
postId: string;
|
||||
replyId: string | null;
|
||||
};
|
||||
|
||||
type NavigationGuard = (target: GuardedNavigation) => boolean;
|
||||
|
||||
type GuardRegistration = {
|
||||
guard: NavigationGuard;
|
||||
};
|
||||
|
||||
const activeGuards: GuardRegistration[] = [];
|
||||
|
||||
export function allowNavigation(target: GuardedNavigation): boolean {
|
||||
return activeGuards.at(-1)?.guard(target) ?? true;
|
||||
}
|
||||
|
||||
export function traverseHistory(
|
||||
history: Pick<History, "back" | "forward">,
|
||||
direction: "back" | "forward",
|
||||
): boolean {
|
||||
if (!allowNavigation({ kind: "history", direction })) {
|
||||
return false;
|
||||
}
|
||||
|
||||
history[direction]();
|
||||
return true;
|
||||
}
|
||||
|
||||
export function registerNavigationGuard(guard: NavigationGuard): () => void {
|
||||
const registration = { guard };
|
||||
activeGuards.push(registration);
|
||||
return () => {
|
||||
const index = activeGuards.lastIndexOf(registration);
|
||||
if (index >= 0) activeGuards.splice(index, 1);
|
||||
};
|
||||
}
|
||||
141
desktop/src/app/navigation/searchHitNavigation.test.mjs
Normal file
141
desktop/src/app/navigation/searchHitNavigation.test.mjs
Normal file
@@ -0,0 +1,141 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
activateDesktopNotificationTarget,
|
||||
createDesktopNotificationActivationQueue,
|
||||
} from "../AppShell.helpers.ts";
|
||||
|
||||
const { clearSearchHitEventCache, getCachedSearchHitEvent } = await import(
|
||||
"./searchHitEventCache.ts"
|
||||
);
|
||||
const { openSearchHitWithNavigation } = await import(
|
||||
"./searchHitNavigation.ts"
|
||||
);
|
||||
|
||||
const forumComment = {
|
||||
eventId: "comment",
|
||||
content: "reply",
|
||||
kind: 45003,
|
||||
pubkey: "author",
|
||||
channelId: "old-community-channel",
|
||||
channelName: "forum",
|
||||
createdAt: 1,
|
||||
score: 0,
|
||||
threadRootId: null,
|
||||
};
|
||||
|
||||
const plainMessage = {
|
||||
...forumComment,
|
||||
eventId: "message",
|
||||
kind: 9,
|
||||
threadRootId: "thread-root",
|
||||
};
|
||||
|
||||
test("search-hit navigation preserves forced message routing while active", async () => {
|
||||
clearSearchHitEventCache();
|
||||
const calls = [];
|
||||
const result = await openSearchHitWithNavigation(plainMessage, {
|
||||
force: true,
|
||||
goChannel: async (channelId, options) => {
|
||||
calls.push({ channelId, options });
|
||||
return true;
|
||||
},
|
||||
goForumPost: async () => false,
|
||||
});
|
||||
|
||||
assert.equal(result, true);
|
||||
assert.deepEqual(calls, [
|
||||
{
|
||||
channelId: "old-community-channel",
|
||||
options: {
|
||||
force: true,
|
||||
messageId: "message",
|
||||
threadRootId: "thread-root",
|
||||
},
|
||||
},
|
||||
]);
|
||||
assert.equal(getCachedSearchHitEvent("message")?.id, "message");
|
||||
});
|
||||
|
||||
test("cancelled search-hit navigation cannot repopulate cache or route", async () => {
|
||||
clearSearchHitEventCache();
|
||||
let resolveLookup;
|
||||
const destination = new Promise((resolve) => {
|
||||
resolveLookup = resolve;
|
||||
});
|
||||
const calls = [];
|
||||
const controller = new AbortController();
|
||||
const navigation = openSearchHitWithNavigation(
|
||||
forumComment,
|
||||
{
|
||||
goChannel: async () => calls.push("channel"),
|
||||
goForumPost: async () => calls.push("forum"),
|
||||
signal: controller.signal,
|
||||
},
|
||||
() => destination,
|
||||
);
|
||||
|
||||
controller.abort();
|
||||
clearSearchHitEventCache();
|
||||
resolveLookup({
|
||||
kind: "forum-post",
|
||||
channelId: "old-community-channel",
|
||||
postId: "old-community-post",
|
||||
replyId: "comment",
|
||||
});
|
||||
await navigation;
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
assert.equal(getCachedSearchHitEvent("comment"), null);
|
||||
});
|
||||
|
||||
test("queue cancellation fences an in-flight forum-comment activation", async () => {
|
||||
clearSearchHitEventCache();
|
||||
let resolveLookup;
|
||||
const destination = new Promise((resolve) => {
|
||||
resolveLookup = resolve;
|
||||
});
|
||||
const calls = [];
|
||||
const queue = createDesktopNotificationActivationQueue((target, signal) =>
|
||||
activateDesktopNotificationTarget(
|
||||
target,
|
||||
{
|
||||
goChannel: async () => calls.push("channel"),
|
||||
goHome: async () => calls.push("home"),
|
||||
openSearchHit: (hit, behavior) =>
|
||||
openSearchHitWithNavigation(
|
||||
hit,
|
||||
{
|
||||
force: behavior?.force,
|
||||
goChannel: async () => calls.push("channel"),
|
||||
goForumPost: async () => calls.push("forum"),
|
||||
signal: behavior?.signal,
|
||||
},
|
||||
() => destination,
|
||||
),
|
||||
revealWindow: async () => {},
|
||||
},
|
||||
signal,
|
||||
),
|
||||
);
|
||||
|
||||
queue.enqueue({
|
||||
channelId: "old-community-channel",
|
||||
eventId: "comment",
|
||||
kind: 45003,
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
queue.cancel();
|
||||
clearSearchHitEventCache();
|
||||
resolveLookup({
|
||||
kind: "forum-post",
|
||||
channelId: "old-community-channel",
|
||||
postId: "old-community-post",
|
||||
replyId: "comment",
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(calls, []);
|
||||
assert.equal(getCachedSearchHitEvent("comment"), null);
|
||||
});
|
||||
60
desktop/src/app/navigation/searchHitNavigation.ts
Normal file
60
desktop/src/app/navigation/searchHitNavigation.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination";
|
||||
import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache";
|
||||
import type { SearchHit } from "@/shared/api/types";
|
||||
|
||||
type SearchHitNavigationActions = {
|
||||
force?: boolean;
|
||||
goChannel: (
|
||||
channelId: string,
|
||||
options?: {
|
||||
force?: boolean;
|
||||
messageId?: string;
|
||||
threadRootId?: string | null;
|
||||
},
|
||||
) => Promise<unknown>;
|
||||
goForumPost: (
|
||||
channelId: string,
|
||||
postId: string,
|
||||
options?: { force?: boolean; replyId?: string },
|
||||
) => Promise<unknown>;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export async function openSearchHitWithNavigation(
|
||||
hit: SearchHit,
|
||||
actions: SearchHitNavigationActions,
|
||||
resolveDestination = resolveSearchHitDestination,
|
||||
): Promise<unknown> {
|
||||
if (actions.signal?.aborted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const isLifecycleBound = Boolean(actions.signal);
|
||||
if (!isLifecycleBound) {
|
||||
cacheSearchHitEvent(hit);
|
||||
}
|
||||
|
||||
const destination = await resolveDestination(hit);
|
||||
if (!destination || actions.signal?.aborted) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (isLifecycleBound) {
|
||||
// Delay community-scoped writes for notification routing until async
|
||||
// destination resolution completes and its owner is still current.
|
||||
cacheSearchHitEvent(hit);
|
||||
}
|
||||
|
||||
if (destination.kind === "forum-post") {
|
||||
return actions.goForumPost(destination.channelId, destination.postId, {
|
||||
force: actions.force,
|
||||
replyId: destination.replyId,
|
||||
});
|
||||
}
|
||||
|
||||
return actions.goChannel(destination.channelId, {
|
||||
force: actions.force,
|
||||
messageId: destination.messageId,
|
||||
threadRootId: destination.threadRootId,
|
||||
});
|
||||
}
|
||||
@@ -6,11 +6,16 @@ import {
|
||||
useRouter,
|
||||
} from "@tanstack/react-router";
|
||||
|
||||
import { cacheSearchHitEvent } from "@/app/navigation/searchHitEventCache";
|
||||
import { resolveSearchHitDestination } from "@/app/navigation/resolveSearchHitDestination";
|
||||
import { openSearchHitWithNavigation } from "@/app/navigation/searchHitNavigation";
|
||||
import {
|
||||
allowNavigation,
|
||||
type GuardedNavigation,
|
||||
traverseHistory,
|
||||
} from "@/app/navigation/navigationGuard";
|
||||
import type { SearchHit } from "@/shared/api/types";
|
||||
|
||||
type NavigationBehavior = {
|
||||
force?: boolean;
|
||||
replace?: boolean;
|
||||
resetScroll?: boolean;
|
||||
};
|
||||
@@ -27,12 +32,22 @@ export function useAppNavigation() {
|
||||
to: string;
|
||||
params?: Record<string, string>;
|
||||
search?: Record<string, string | undefined>;
|
||||
state?: Record<string, unknown>;
|
||||
},
|
||||
behavior: NavigationBehavior = {},
|
||||
guardedTarget?: GuardedNavigation,
|
||||
) => {
|
||||
const nextLocation = router.buildLocation(next as never);
|
||||
|
||||
if (location.href === nextLocation.href) {
|
||||
if (location.href === nextLocation.href && !behavior.force) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (
|
||||
!allowNavigation(
|
||||
guardedTarget ?? { kind: "route", href: nextLocation.href },
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
@@ -110,6 +125,11 @@ export function useAppNavigation() {
|
||||
pullRequestId?: string;
|
||||
issueId?: string;
|
||||
repositoryId?: string;
|
||||
/** Workspace tab requested by a share link (link vocabulary). */
|
||||
tab?: string;
|
||||
/** Unique per entity-link activation so repeating the same link can
|
||||
* re-apply an unchanged tab selection. */
|
||||
entityNavigationId?: string;
|
||||
},
|
||||
) =>
|
||||
commitNavigation(
|
||||
@@ -129,9 +149,16 @@ export function useAppNavigation() {
|
||||
...(behavior?.repositoryId
|
||||
? { repositoryId: behavior.repositoryId }
|
||||
: {}),
|
||||
...(behavior?.tab ? { tab: behavior.tab } : {}),
|
||||
},
|
||||
state: behavior?.entityNavigationId
|
||||
? { entityNavigationId: behavior.entityNavigationId }
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
...behavior,
|
||||
force: Boolean(behavior?.entityNavigationId),
|
||||
},
|
||||
behavior,
|
||||
),
|
||||
[commitNavigation],
|
||||
);
|
||||
@@ -155,6 +182,66 @@ export function useAppNavigation() {
|
||||
params: {
|
||||
workflowId,
|
||||
},
|
||||
search: { pane: "trigger" },
|
||||
state: { workflowEditorHasOrigin: true },
|
||||
},
|
||||
behavior,
|
||||
),
|
||||
[commitNavigation],
|
||||
);
|
||||
|
||||
const goNewWorkflow = React.useCallback(
|
||||
(behavior?: NavigationBehavior) =>
|
||||
commitNavigation(
|
||||
{
|
||||
to: "/workflows",
|
||||
search: { pane: "trigger", view: "create" },
|
||||
state: { workflowEditorHasOrigin: true },
|
||||
},
|
||||
behavior,
|
||||
),
|
||||
[commitNavigation],
|
||||
);
|
||||
|
||||
const goNewWorkflowForChannel = React.useCallback(
|
||||
(channelId: string, behavior?: NavigationBehavior) =>
|
||||
commitNavigation(
|
||||
{
|
||||
to: "/workflows",
|
||||
search: {
|
||||
channel: channelId,
|
||||
pane: "trigger",
|
||||
view: "create",
|
||||
},
|
||||
state: { workflowEditorHasOrigin: true },
|
||||
},
|
||||
behavior,
|
||||
),
|
||||
[commitNavigation],
|
||||
);
|
||||
|
||||
const goEditWorkflow = React.useCallback(
|
||||
(workflowId: string, behavior?: NavigationBehavior) =>
|
||||
commitNavigation(
|
||||
{
|
||||
to: "/workflows/$workflowId",
|
||||
params: { workflowId },
|
||||
search: { pane: "trigger", view: "edit" },
|
||||
state: { workflowEditorHasOrigin: true },
|
||||
},
|
||||
behavior,
|
||||
),
|
||||
[commitNavigation],
|
||||
);
|
||||
|
||||
const goDuplicateWorkflow = React.useCallback(
|
||||
(workflowId: string, behavior?: NavigationBehavior) =>
|
||||
commitNavigation(
|
||||
{
|
||||
to: "/workflows/$workflowId",
|
||||
params: { workflowId },
|
||||
search: { pane: "trigger", view: "duplicate" },
|
||||
state: { workflowEditorHasOrigin: true },
|
||||
},
|
||||
behavior,
|
||||
),
|
||||
@@ -173,14 +260,18 @@ export function useAppNavigation() {
|
||||
* firing. Used by the Drafts panel "Send message" confirm flow.
|
||||
*/
|
||||
autoSend?: string;
|
||||
/** Navigate even when the destination matches the current href.
|
||||
* Used by desktop-notification activation so a click is never
|
||||
* silently swallowed (block/buzz#3509). */
|
||||
force?: boolean;
|
||||
messageId?: string;
|
||||
replace?: boolean;
|
||||
/** Open this thread panel directly without waiting for a timeline row. */
|
||||
thread?: string;
|
||||
threadRootId?: string | null;
|
||||
},
|
||||
) =>
|
||||
commitNavigation(
|
||||
) => {
|
||||
return commitNavigation(
|
||||
{
|
||||
to: "/channels/$channelId",
|
||||
params: {
|
||||
@@ -201,10 +292,20 @@ export function useAppNavigation() {
|
||||
},
|
||||
},
|
||||
{
|
||||
force: options?.force,
|
||||
replace: options?.replace,
|
||||
resetScroll: options?.messageId ? true : undefined,
|
||||
},
|
||||
),
|
||||
options?.messageId
|
||||
? {
|
||||
kind: "channel-message",
|
||||
channelId,
|
||||
messageId: options.messageId,
|
||||
threadRootId: options.threadRootId ?? null,
|
||||
}
|
||||
: undefined,
|
||||
);
|
||||
},
|
||||
[commitNavigation],
|
||||
);
|
||||
|
||||
@@ -224,11 +325,13 @@ export function useAppNavigation() {
|
||||
channelId: string,
|
||||
postId: string,
|
||||
options?: {
|
||||
/** Navigate even when the destination matches the current href. */
|
||||
force?: boolean;
|
||||
replace?: boolean;
|
||||
replyId?: string;
|
||||
},
|
||||
) =>
|
||||
commitNavigation(
|
||||
) => {
|
||||
return commitNavigation(
|
||||
{
|
||||
to: "/channels/$channelId/posts/$postId",
|
||||
params: {
|
||||
@@ -238,10 +341,18 @@ export function useAppNavigation() {
|
||||
search: options?.replyId ? { replyId: options.replyId } : {},
|
||||
},
|
||||
{
|
||||
force: options?.force,
|
||||
replace: options?.replace,
|
||||
resetScroll: false,
|
||||
},
|
||||
),
|
||||
{
|
||||
kind: "forum-post",
|
||||
channelId,
|
||||
postId,
|
||||
replyId: options?.replyId ?? null,
|
||||
},
|
||||
);
|
||||
},
|
||||
[commitNavigation],
|
||||
);
|
||||
|
||||
@@ -259,7 +370,7 @@ export function useAppNavigation() {
|
||||
|
||||
const closeSettings = React.useCallback(() => {
|
||||
if (canGoBack) {
|
||||
router.history.back();
|
||||
traverseHistory(router.history, "back");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -268,7 +379,7 @@ export function useAppNavigation() {
|
||||
|
||||
const closeWorkflowDetail = React.useCallback(() => {
|
||||
if (canGoBack) {
|
||||
router.history.back();
|
||||
traverseHistory(router.history, "back");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -278,7 +389,7 @@ export function useAppNavigation() {
|
||||
const closeForumPost = React.useCallback(
|
||||
(channelId: string) => {
|
||||
if (canGoBack) {
|
||||
router.history.back();
|
||||
traverseHistory(router.history, "back");
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -288,25 +399,23 @@ export function useAppNavigation() {
|
||||
);
|
||||
|
||||
const openSearchHit = React.useCallback(
|
||||
async (hit: SearchHit) => {
|
||||
cacheSearchHitEvent(hit);
|
||||
|
||||
const destination = await resolveSearchHitDestination(hit);
|
||||
if (!destination) {
|
||||
return false;
|
||||
}
|
||||
|
||||
if (destination.kind === "forum-post") {
|
||||
return goForumPost(destination.channelId, destination.postId, {
|
||||
replyId: destination.replyId,
|
||||
});
|
||||
}
|
||||
|
||||
return goChannel(destination.channelId, {
|
||||
messageId: destination.messageId,
|
||||
threadRootId: destination.threadRootId,
|
||||
});
|
||||
},
|
||||
async (
|
||||
hit: SearchHit,
|
||||
behavior?: {
|
||||
/** Navigate even when the destination matches the current href.
|
||||
* Used by desktop-notification activation so a click is never
|
||||
* silently swallowed (block/buzz#3509). */
|
||||
force?: boolean;
|
||||
/** Stop notification-driven routing when its owning lifecycle ends. */
|
||||
signal?: AbortSignal;
|
||||
},
|
||||
) =>
|
||||
openSearchHitWithNavigation(hit, {
|
||||
force: behavior?.force,
|
||||
goChannel,
|
||||
goForumPost,
|
||||
signal: behavior?.signal,
|
||||
}),
|
||||
[goChannel, goForumPost],
|
||||
);
|
||||
|
||||
@@ -316,9 +425,13 @@ export function useAppNavigation() {
|
||||
closeWorkflowDetail,
|
||||
goAgents,
|
||||
goChannel,
|
||||
goDuplicateWorkflow,
|
||||
goEditWorkflow,
|
||||
goForumPost,
|
||||
goHome,
|
||||
goNewMessage,
|
||||
goNewWorkflow,
|
||||
goNewWorkflowForChannel,
|
||||
goProject,
|
||||
goProjects,
|
||||
goPulse,
|
||||
|
||||
@@ -8,6 +8,7 @@ import { isTauri } from "@tauri-apps/api/core";
|
||||
import { listen } from "@tauri-apps/api/event";
|
||||
|
||||
import { matchBackForwardChord } from "@/app/navigation/backForwardChords";
|
||||
import { traverseHistory } from "@/app/navigation/navigationGuard";
|
||||
import { isMacPlatform } from "@/shared/lib/platform";
|
||||
import { trimMapToSize } from "@/shared/lib/trimMapToSize";
|
||||
|
||||
@@ -59,7 +60,7 @@ export function useBackForwardControls() {
|
||||
return;
|
||||
}
|
||||
|
||||
router.history.back();
|
||||
traverseHistory(router.history, "back");
|
||||
}, [canGoBack, router.history]);
|
||||
|
||||
const goForward = React.useCallback(() => {
|
||||
@@ -67,7 +68,7 @@ export function useBackForwardControls() {
|
||||
return;
|
||||
}
|
||||
|
||||
router.history.forward();
|
||||
traverseHistory(router.history, "forward");
|
||||
}, [canGoForward, router.history]);
|
||||
|
||||
const handleKeyDown = React.useEffectEvent((event: KeyboardEvent) => {
|
||||
|
||||
@@ -3,6 +3,7 @@ import * as React from "react";
|
||||
import { getCachedSearchHitEvent } from "@/app/navigation/searchHitEventCache";
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import { useOpenChannelDirectoryQuery } from "@/features/channels/openChannelDirectory";
|
||||
import { ChannelScreen } from "@/features/channels/ui/ChannelScreen";
|
||||
import { HuddleStartingView } from "@/features/huddle/components/HuddleStartingView";
|
||||
import { huddleWindowChannelId } from "@/features/huddle/lib/huddleWindow";
|
||||
@@ -110,8 +111,21 @@ export function ChannelRouteScreen({
|
||||
const identityQuery = useIdentityQuery();
|
||||
const profileQuery = useProfileQuery();
|
||||
const channels = channelsQuery.data ?? [];
|
||||
const activeChannel =
|
||||
const memberChannel =
|
||||
channels.find((channel) => channel.id === channelId) ?? null;
|
||||
// A deep link to a non-member open channel resolves nothing in the
|
||||
// member-only poll list. Fall back to the discovery directory — but only for
|
||||
// that case, so a normal in-membership route never triggers the all-open
|
||||
// scan. React Query dedups the shared directory key across surfaces.
|
||||
const needsDirectoryFallback =
|
||||
!memberChannel && channelsQuery.isSuccess && !isHuddleTranscript;
|
||||
const openDirectoryQuery = useOpenChannelDirectoryQuery({
|
||||
enabled: needsDirectoryFallback,
|
||||
});
|
||||
const activeChannel =
|
||||
memberChannel ??
|
||||
openDirectoryQuery.data?.find((channel) => channel.id === channelId) ??
|
||||
null;
|
||||
const [targetMessageEvents, setTargetMessageEvents] = React.useState<
|
||||
RelayEvent[]
|
||||
>(() => {
|
||||
@@ -188,7 +202,11 @@ export function ChannelRouteScreen({
|
||||
};
|
||||
}, [selectedPostId, targetMessageId, targetThreadRootId]);
|
||||
|
||||
if (channelsQuery.isPending && !activeChannel) {
|
||||
if (
|
||||
!activeChannel &&
|
||||
(channelsQuery.isPending ||
|
||||
(needsDirectoryFallback && openDirectoryQuery.isPending))
|
||||
) {
|
||||
if (isHuddleTranscript) {
|
||||
return <HuddleStartingView />;
|
||||
}
|
||||
|
||||
@@ -1,15 +1,37 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { useAppNavigation } from "@/app/navigation/useAppNavigation";
|
||||
import { useChannelsQuery } from "@/features/channels/hooks";
|
||||
import { WorkflowsScreen } from "@/features/workflows/ui/WorkflowsScreen";
|
||||
import {
|
||||
type WorkflowEditorRoute,
|
||||
WorkflowsScreen,
|
||||
} from "@/features/workflows/ui/WorkflowsScreen";
|
||||
import type { WorkflowEditorPane } from "@/features/workflows/ui/workflowEditorPane";
|
||||
|
||||
type WorkflowsRouteScreenProps = {
|
||||
selectedWorkflowId: string | null;
|
||||
editor?: WorkflowEditorRoute | null;
|
||||
onEditorPaneChange: (pane: WorkflowEditorPane) => void;
|
||||
};
|
||||
|
||||
export function WorkflowsRouteScreen({
|
||||
selectedWorkflowId,
|
||||
editor = null,
|
||||
onEditorPaneChange,
|
||||
}: WorkflowsRouteScreenProps) {
|
||||
const { closeWorkflowDetail, goWorkflow } = useAppNavigation();
|
||||
const {
|
||||
closeWorkflowDetail,
|
||||
goDuplicateWorkflow,
|
||||
goEditWorkflow,
|
||||
goNewWorkflow,
|
||||
goWorkflow,
|
||||
goWorkflows,
|
||||
} = useAppNavigation();
|
||||
const closeEditor = React.useCallback(() => {
|
||||
if (editor?.hasOrigin) {
|
||||
closeWorkflowDetail();
|
||||
return;
|
||||
}
|
||||
void goWorkflows({ replace: true });
|
||||
}, [closeWorkflowDetail, editor?.hasOrigin, goWorkflows]);
|
||||
const channelsQuery = useChannelsQuery();
|
||||
const channels = channelsQuery.data ?? [];
|
||||
const memberChannels = channels.filter((channel) => channel.isMember);
|
||||
@@ -17,11 +39,21 @@ export function WorkflowsRouteScreen({
|
||||
return (
|
||||
<WorkflowsScreen
|
||||
channels={memberChannels}
|
||||
onCloseWorkflow={closeWorkflowDetail}
|
||||
onSelectWorkflow={(workflowId) => {
|
||||
editor={editor}
|
||||
onCloseEditor={closeEditor}
|
||||
onCreateWorkflow={() => {
|
||||
void goNewWorkflow();
|
||||
}}
|
||||
onDuplicateWorkflow={(workflowId) => {
|
||||
void goDuplicateWorkflow(workflowId);
|
||||
}}
|
||||
onEditWorkflow={(workflowId) => {
|
||||
void goEditWorkflow(workflowId);
|
||||
}}
|
||||
onViewWorkflow={(workflowId) => {
|
||||
void goWorkflow(workflowId);
|
||||
}}
|
||||
selectedWorkflowId={selectedWorkflowId}
|
||||
onEditorPaneChange={onEditorPaneChange}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
6
desktop/src/app/routes/lazyWorkflowsRouteScreen.ts
Normal file
6
desktop/src/app/routes/lazyWorkflowsRouteScreen.ts
Normal file
@@ -0,0 +1,6 @@
|
||||
import * as React from "react";
|
||||
|
||||
export const LazyWorkflowsRouteScreen = React.lazy(async () => {
|
||||
const module = await import("./WorkflowsRouteScreen");
|
||||
return { default: module.WorkflowsRouteScreen };
|
||||
});
|
||||
@@ -1,7 +1,8 @@
|
||||
import * as React from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, useLocation } from "@tanstack/react-router";
|
||||
|
||||
import { usePreviewFeatureWarning } from "@/shared/features";
|
||||
import { isEntityLinkTab } from "@/shared/lib/entityLink";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
|
||||
const ProjectDetailScreen = React.lazy(async () => {
|
||||
@@ -21,23 +22,34 @@ export const Route = createFileRoute("/projects/$projectId")({
|
||||
issueId: typeof search.issueId === "string" ? search.issueId : undefined,
|
||||
repositoryId:
|
||||
typeof search.repositoryId === "string" ? search.repositoryId : undefined,
|
||||
tab: isEntityLinkTab(search.tab) ? search.tab : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
function ProjectDetailRouteComponent() {
|
||||
usePreviewFeatureWarning("projects");
|
||||
const { projectId } = Route.useParams();
|
||||
const { commitHash, pullRequestId, issueId, repositoryId } =
|
||||
const { commitHash, pullRequestId, issueId, repositoryId, tab } =
|
||||
Route.useSearch();
|
||||
const entityNavigationId = useLocation({
|
||||
select: (location) => {
|
||||
const value = (
|
||||
location.state as { entityNavigationId?: unknown } | undefined
|
||||
)?.entityNavigationId;
|
||||
return typeof value === "string" ? value : undefined;
|
||||
},
|
||||
});
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="projects" />}>
|
||||
<ProjectDetailScreen
|
||||
commitHash={commitHash}
|
||||
entityNavigationId={entityNavigationId}
|
||||
issueId={issueId}
|
||||
projectId={projectId}
|
||||
pullRequestId={pullRequestId}
|
||||
repositoryId={repositoryId}
|
||||
tab={tab}
|
||||
/>
|
||||
</React.Suspense>
|
||||
);
|
||||
|
||||
@@ -1,25 +1,62 @@
|
||||
import * as React from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, useLocation } from "@tanstack/react-router";
|
||||
|
||||
import {
|
||||
parseWorkflowEditorPane,
|
||||
serializeWorkflowEditorPane,
|
||||
} from "@/features/workflows/ui/workflowEditorPane";
|
||||
import { usePreviewFeatureWarning } from "@/shared/features";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
import { LazyWorkflowsRouteScreen } from "./lazyWorkflowsRouteScreen";
|
||||
|
||||
export const Route = createFileRoute("/workflows/$workflowId")({
|
||||
component: WorkflowDetailRouteComponent,
|
||||
component: WorkflowRouteComponent,
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
pane: serializeWorkflowEditorPane(parseWorkflowEditorPane(search.pane)),
|
||||
view:
|
||||
search.view === "edit" || search.view === "duplicate"
|
||||
? search.view
|
||||
: undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
const WorkflowsRouteScreen = React.lazy(async () => {
|
||||
const module = await import("./WorkflowsRouteScreen");
|
||||
return { default: module.WorkflowsRouteScreen };
|
||||
});
|
||||
|
||||
function WorkflowDetailRouteComponent() {
|
||||
function WorkflowRouteComponent() {
|
||||
usePreviewFeatureWarning("workflows");
|
||||
const navigate = Route.useNavigate();
|
||||
const location = useLocation();
|
||||
const { workflowId } = Route.useParams();
|
||||
const { pane, view } = Route.useSearch();
|
||||
const hasOrigin =
|
||||
(location.state as { workflowEditorHasOrigin?: unknown } | undefined)
|
||||
?.workflowEditorHasOrigin === true;
|
||||
const editor: import("@/features/workflows/ui/WorkflowsScreen").WorkflowEditorRoute =
|
||||
{
|
||||
hasOrigin,
|
||||
mode:
|
||||
view === "duplicate"
|
||||
? "duplicate"
|
||||
: view === "edit"
|
||||
? "edit"
|
||||
: "detail",
|
||||
pane: parseWorkflowEditorPane(pane),
|
||||
workflowId,
|
||||
};
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
|
||||
<WorkflowsRouteScreen selectedWorkflowId={workflowId} />
|
||||
<LazyWorkflowsRouteScreen
|
||||
editor={editor}
|
||||
onEditorPaneChange={(nextPane) => {
|
||||
void navigate({
|
||||
replace: true,
|
||||
resetScroll: false,
|
||||
search: {
|
||||
pane: serializeWorkflowEditorPane(nextPane),
|
||||
view,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,23 +1,57 @@
|
||||
import * as React from "react";
|
||||
import { createFileRoute } from "@tanstack/react-router";
|
||||
import { createFileRoute, useLocation } from "@tanstack/react-router";
|
||||
|
||||
import {
|
||||
parseWorkflowEditorPane,
|
||||
serializeWorkflowEditorPane,
|
||||
} from "@/features/workflows/ui/workflowEditorPane";
|
||||
import { usePreviewFeatureWarning } from "@/shared/features";
|
||||
import { ViewLoadingFallback } from "@/shared/ui/ViewLoadingFallback";
|
||||
import { LazyWorkflowsRouteScreen } from "./lazyWorkflowsRouteScreen";
|
||||
|
||||
export const Route = createFileRoute("/workflows")({
|
||||
component: WorkflowsRouteComponent,
|
||||
});
|
||||
|
||||
const WorkflowsRouteScreen = React.lazy(async () => {
|
||||
const module = await import("./WorkflowsRouteScreen");
|
||||
return { default: module.WorkflowsRouteScreen };
|
||||
validateSearch: (search: Record<string, unknown>) => ({
|
||||
channel: typeof search.channel === "string" ? search.channel : undefined,
|
||||
pane: serializeWorkflowEditorPane(parseWorkflowEditorPane(search.pane)),
|
||||
view: search.view === "create" ? search.view : undefined,
|
||||
}),
|
||||
});
|
||||
|
||||
function WorkflowsRouteComponent() {
|
||||
usePreviewFeatureWarning("workflows");
|
||||
const navigate = Route.useNavigate();
|
||||
const location = useLocation();
|
||||
const { channel, pane, view } = Route.useSearch();
|
||||
const hasOrigin =
|
||||
(location.state as { workflowEditorHasOrigin?: unknown } | undefined)
|
||||
?.workflowEditorHasOrigin === true;
|
||||
|
||||
return (
|
||||
<React.Suspense fallback={<ViewLoadingFallback kind="workflows" />}>
|
||||
<WorkflowsRouteScreen selectedWorkflowId={null} />
|
||||
<LazyWorkflowsRouteScreen
|
||||
editor={
|
||||
view === "create"
|
||||
? {
|
||||
hasOrigin,
|
||||
initialChannelId: channel,
|
||||
mode: "create",
|
||||
pane: parseWorkflowEditorPane(pane),
|
||||
}
|
||||
: null
|
||||
}
|
||||
onEditorPaneChange={(nextPane) => {
|
||||
void navigate({
|
||||
replace: true,
|
||||
resetScroll: false,
|
||||
search: {
|
||||
channel,
|
||||
pane: serializeWorkflowEditorPane(nextPane),
|
||||
view,
|
||||
},
|
||||
});
|
||||
}}
|
||||
/>
|
||||
</React.Suspense>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,10 +1,10 @@
|
||||
import * as React from "react";
|
||||
|
||||
import {
|
||||
activateDesktopNotificationTarget,
|
||||
createDesktopNotificationActivationQueue,
|
||||
shouldBounceForChannelNotification,
|
||||
toSearchHit,
|
||||
} from "@/app/AppShell.helpers";
|
||||
import { getThreadReference } from "@/features/messages/lib/threading";
|
||||
import { useCommunityJoinAlerts } from "@/features/community-members/useCommunityJoinAlerts";
|
||||
import { hasMentionForEvent } from "@/features/notifications/lib/shouldNotify";
|
||||
import type { NotificationSettings } from "@/features/notifications/hooks";
|
||||
@@ -14,15 +14,14 @@ import {
|
||||
revealDesktopAppWindow,
|
||||
sendDesktopNotification,
|
||||
} from "@/features/notifications/lib/desktop";
|
||||
import {
|
||||
formatNotificationTitle,
|
||||
truncateNotificationBody,
|
||||
} from "@/features/notifications/lib/notificationFormat";
|
||||
import { formatMessageNotification } from "@/features/notifications/lib/notificationFormat";
|
||||
import { buildEventNotificationTarget } from "@/features/notifications/lib/target";
|
||||
import {
|
||||
playNotificationSound,
|
||||
resolveSlotSound,
|
||||
shouldPlayNotificationSound,
|
||||
} from "@/features/notifications/lib/sound";
|
||||
import { useNotificationSenderName } from "@/features/notifications/useNotificationSenderName";
|
||||
import type { Channel, RelayEvent } from "@/shared/api/types";
|
||||
|
||||
export function useAppShellDesktopNotifications({
|
||||
@@ -37,11 +36,15 @@ export function useAppShellDesktopNotifications({
|
||||
}: {
|
||||
channels: Channel[];
|
||||
enabled: boolean;
|
||||
goChannel: (channelId: string) => Promise<unknown>;
|
||||
goChannel: (
|
||||
channelId: string,
|
||||
options?: { force?: boolean },
|
||||
) => Promise<unknown>;
|
||||
goHome: () => Promise<unknown>;
|
||||
notificationSettings: NotificationSettings;
|
||||
openSearchHit: (
|
||||
hit: import("@/shared/api/types").SearchHit,
|
||||
behavior?: { force?: boolean },
|
||||
) => Promise<unknown>;
|
||||
pubkey?: string;
|
||||
silentChannelIds?: ReadonlySet<string>;
|
||||
@@ -53,6 +56,8 @@ export function useAppShellDesktopNotifications({
|
||||
enabled: enabled && notificationSettings.desktopEnabled,
|
||||
});
|
||||
|
||||
const resolveSenderName = useNotificationSenderName();
|
||||
|
||||
const handleChannelNotification = React.useEffectEvent(
|
||||
(_channelId: string, event: RelayEvent) => {
|
||||
if (!enabled) return;
|
||||
@@ -73,22 +78,20 @@ export function useAppShellDesktopNotifications({
|
||||
}
|
||||
|
||||
const channelName = channel.name?.trim() || "Direct message";
|
||||
const body = truncateNotificationBody(event.content, "New message");
|
||||
const threadRootId = getThreadReference(event.tags).rootId ?? null;
|
||||
const { title, body } = formatMessageNotification({
|
||||
source: "dm",
|
||||
senderName: resolveSenderName(event.pubkey),
|
||||
channelName,
|
||||
content: event.content,
|
||||
});
|
||||
|
||||
void sendDesktopNotification({
|
||||
title: channelName,
|
||||
title,
|
||||
body,
|
||||
target: {
|
||||
channelId: channel.id,
|
||||
channelName,
|
||||
content: event.content,
|
||||
createdAt: event.created_at,
|
||||
eventId: event.id,
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
threadRootId,
|
||||
},
|
||||
target: buildEventNotificationTarget(event, {
|
||||
id: channel.id,
|
||||
name: channelName,
|
||||
}),
|
||||
}).then((didSend) => {
|
||||
if (!didSend) return;
|
||||
if (shouldPlayNotificationSound(channel.id, silentChannelIds)) {
|
||||
@@ -118,25 +121,20 @@ export function useAppShellDesktopNotifications({
|
||||
|
||||
const resolvedChannel = channels.find((c) => c.id === channelId);
|
||||
const channelName = resolvedChannel?.name?.trim() ?? null;
|
||||
// channelLabel is "#name" for the toast title; channelName is the raw
|
||||
// name stored in the navigation target for click-through routing.
|
||||
const channelLabel = channelName ? `#${channelName}` : null;
|
||||
const body = truncateNotificationBody(event.content, "New reply");
|
||||
const threadRootId = getThreadReference(event.tags).rootId ?? null;
|
||||
const { title, body } = formatMessageNotification({
|
||||
source: "thread_reply",
|
||||
senderName: resolveSenderName(event.pubkey),
|
||||
channelName,
|
||||
content: event.content,
|
||||
});
|
||||
|
||||
void sendDesktopNotification({
|
||||
title: formatNotificationTitle({ prefix: "Reply", channelLabel }),
|
||||
title,
|
||||
body,
|
||||
target: {
|
||||
channelId,
|
||||
channelName,
|
||||
content: event.content,
|
||||
createdAt: event.created_at,
|
||||
eventId: event.id,
|
||||
kind: event.kind,
|
||||
pubkey: event.pubkey,
|
||||
threadRootId,
|
||||
},
|
||||
target: buildEventNotificationTarget(event, {
|
||||
id: channelId,
|
||||
name: channelName,
|
||||
}),
|
||||
}).then((didSend) => {
|
||||
if (!didSend) return;
|
||||
if (shouldPlayNotificationSound(channelId, silentChannelIds)) {
|
||||
@@ -152,21 +150,18 @@ export function useAppShellDesktopNotifications({
|
||||
const handleDesktopNotificationAction = React.useEffectEvent(
|
||||
async (
|
||||
target: import("@/features/notifications/lib/desktop").DesktopNotificationTarget,
|
||||
signal: AbortSignal,
|
||||
) => {
|
||||
await revealDesktopAppWindow();
|
||||
|
||||
if (!target.channelId) {
|
||||
void goHome();
|
||||
return;
|
||||
}
|
||||
|
||||
const anchor = toSearchHit(target);
|
||||
if (!anchor) {
|
||||
await goChannel(target.channelId);
|
||||
return;
|
||||
}
|
||||
|
||||
await openSearchHit(anchor);
|
||||
await activateDesktopNotificationTarget(
|
||||
target,
|
||||
{
|
||||
goChannel,
|
||||
goHome,
|
||||
openSearchHit,
|
||||
revealWindow: revealDesktopAppWindow,
|
||||
},
|
||||
signal,
|
||||
);
|
||||
},
|
||||
);
|
||||
|
||||
@@ -174,13 +169,19 @@ export function useAppShellDesktopNotifications({
|
||||
if (!enabled) return;
|
||||
let isCancelled = false;
|
||||
let cleanup = () => {};
|
||||
const activationQueue = createDesktopNotificationActivationQueue(
|
||||
(target, signal) => handleDesktopNotificationAction(target, signal),
|
||||
(error) => {
|
||||
console.error("Failed to activate desktop notification", error);
|
||||
},
|
||||
);
|
||||
|
||||
void listenForDesktopNotificationActions((target) => {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleDesktopNotificationAction(target);
|
||||
activationQueue.enqueue(target);
|
||||
}).then((dispose) => {
|
||||
if (isCancelled) {
|
||||
dispose();
|
||||
@@ -192,6 +193,7 @@ export function useAppShellDesktopNotifications({
|
||||
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
activationQueue.cancel();
|
||||
cleanup();
|
||||
};
|
||||
}, [enabled]);
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
|
||||
import {
|
||||
HUDDLE_SHORTCUT_EVENT,
|
||||
type HuddleShortcutDetail,
|
||||
} from "@/shared/lib/keyboard-shortcuts";
|
||||
|
||||
type AppShellKeyboardShortcutsOptions = {
|
||||
activeChannelId: string | null;
|
||||
canSearchCurrentChannel: boolean;
|
||||
disabled: boolean;
|
||||
onBrowseChannels: () => void;
|
||||
@@ -14,6 +19,7 @@ type AppShellKeyboardShortcutsOptions = {
|
||||
};
|
||||
|
||||
export function useAppShellKeyboardShortcuts({
|
||||
activeChannelId,
|
||||
canSearchCurrentChannel,
|
||||
disabled,
|
||||
onBrowseChannels,
|
||||
@@ -26,6 +32,27 @@ export function useAppShellKeyboardShortcuts({
|
||||
React.useLayoutEffect(() => {
|
||||
if (disabled) return;
|
||||
|
||||
// Capture phase so the shortcut works even when a composer or control
|
||||
// has focus and stops keydown propagation. Only the huddle shortcut may
|
||||
// live here: capture-phase handling of the other shortcuts would steal
|
||||
// them from more specific handlers (e.g. the composer's ⌘K link dialog).
|
||||
function handleHuddleShortcut(event: KeyboardEvent) {
|
||||
const isHuddleShortcut =
|
||||
event.ctrlKey &&
|
||||
event.shiftKey &&
|
||||
!event.metaKey &&
|
||||
!event.altKey &&
|
||||
event.code === "Space";
|
||||
if (!isHuddleShortcut) return;
|
||||
if (event.repeat || event.defaultPrevented || !activeChannelId) return;
|
||||
event.preventDefault();
|
||||
window.dispatchEvent(
|
||||
new CustomEvent<HuddleShortcutDetail>(HUDDLE_SHORTCUT_EVENT, {
|
||||
detail: { channelId: activeChannelId },
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (
|
||||
!hasPrimaryShortcutModifier(event) ||
|
||||
@@ -73,9 +100,18 @@ export function useAppShellKeyboardShortcuts({
|
||||
}
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleHuddleShortcut, {
|
||||
capture: true,
|
||||
});
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleHuddleShortcut, {
|
||||
capture: true,
|
||||
});
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
};
|
||||
}, [
|
||||
activeChannelId,
|
||||
canSearchCurrentChannel,
|
||||
disabled,
|
||||
onBrowseChannels,
|
||||
|
||||
@@ -1,25 +1,27 @@
|
||||
import * as React from "react";
|
||||
|
||||
import { setDesktopAppBadge } from "@/features/notifications/lib/desktop";
|
||||
import { useForegroundQueryRefresh } from "@/features/workflows/hooks";
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { useRelayResumeTriggers } from "@/shared/api/useRelayResumeTriggers";
|
||||
|
||||
type AppShellLifecycleEffectsOptions = {
|
||||
desktopBadgeEnabled: boolean;
|
||||
homeBadgeCountExcludingHighPriority: number;
|
||||
unreadChannelIds: ReadonlySet<string>;
|
||||
topLevelUnreadChannelIds: ReadonlySet<string>;
|
||||
unreadChannelNotificationCount: number;
|
||||
};
|
||||
|
||||
export function useAppShellLifecycleEffects({
|
||||
desktopBadgeEnabled,
|
||||
homeBadgeCountExcludingHighPriority,
|
||||
unreadChannelIds,
|
||||
topLevelUnreadChannelIds,
|
||||
unreadChannelNotificationCount,
|
||||
}: AppShellLifecycleEffectsOptions) {
|
||||
// Event-driven reconnect: network online / focus / visibility short-circuit
|
||||
// the backoff timer when the relay session is degraded (CMD+R gap G1).
|
||||
useRelayResumeTriggers();
|
||||
useForegroundQueryRefresh();
|
||||
|
||||
// Prevent webview file:/// navigation on file drop outside the composer.
|
||||
// Scoped to file drags only (text drag-and-drop into inputs still works).
|
||||
@@ -40,33 +42,13 @@ export function useAppShellLifecycleEffects({
|
||||
|
||||
React.useEffect(() => {
|
||||
let isCancelled = false;
|
||||
|
||||
const startPreconnect = () => {
|
||||
if (isCancelled) {
|
||||
return;
|
||||
void relayClient.preconnect().catch((error) => {
|
||||
if (!isCancelled) {
|
||||
console.error("Failed to preconnect to relay", error);
|
||||
}
|
||||
|
||||
void relayClient.preconnect().catch((error) => {
|
||||
if (!isCancelled) {
|
||||
console.error("Failed to preconnect to relay", error);
|
||||
}
|
||||
});
|
||||
};
|
||||
|
||||
if ("requestIdleCallback" in window) {
|
||||
const idleId = window.requestIdleCallback(startPreconnect, {
|
||||
timeout: 1_500,
|
||||
});
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
window.cancelIdleCallback(idleId);
|
||||
};
|
||||
}
|
||||
|
||||
const timeoutId = globalThis.setTimeout(startPreconnect, 250);
|
||||
});
|
||||
return () => {
|
||||
isCancelled = true;
|
||||
globalThis.clearTimeout(timeoutId);
|
||||
};
|
||||
}, []);
|
||||
|
||||
@@ -80,12 +62,12 @@ export function useAppShellLifecycleEffects({
|
||||
void setDesktopAppBadge(
|
||||
count
|
||||
? { kind: "count", count }
|
||||
: { kind: unreadChannelIds.size ? "dot" : "none" },
|
||||
: { kind: topLevelUnreadChannelIds.size ? "dot" : "none" },
|
||||
);
|
||||
}, [
|
||||
desktopBadgeEnabled,
|
||||
homeBadgeCountExcludingHighPriority,
|
||||
unreadChannelIds,
|
||||
topLevelUnreadChannelIds,
|
||||
unreadChannelNotificationCount,
|
||||
]);
|
||||
}
|
||||
|
||||
43
desktop/src/app/useCloseWindowShortcut.test.mjs
Normal file
43
desktop/src/app/useCloseWindowShortcut.test.mjs
Normal file
@@ -0,0 +1,43 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isCloseWindowShortcut } from "./useCloseWindowShortcut.ts";
|
||||
|
||||
function chord(overrides = {}) {
|
||||
return {
|
||||
altKey: false,
|
||||
code: "KeyW",
|
||||
ctrlKey: false,
|
||||
defaultPrevented: false,
|
||||
isComposing: false,
|
||||
metaKey: true,
|
||||
repeat: false,
|
||||
shiftKey: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("Cmd+W closes a window on macOS", () => {
|
||||
assert.equal(isCloseWindowShortcut(chord(), true), true);
|
||||
});
|
||||
|
||||
test("the close-window shortcut rejects other platforms and modified chords", () => {
|
||||
assert.equal(isCloseWindowShortcut(chord(), false), false);
|
||||
assert.equal(isCloseWindowShortcut(chord({ metaKey: false }), true), false);
|
||||
assert.equal(isCloseWindowShortcut(chord({ ctrlKey: true }), true), false);
|
||||
assert.equal(isCloseWindowShortcut(chord({ altKey: true }), true), false);
|
||||
assert.equal(isCloseWindowShortcut(chord({ shiftKey: true }), true), false);
|
||||
assert.equal(isCloseWindowShortcut(chord({ code: "KeyQ" }), true), false);
|
||||
});
|
||||
|
||||
test("handled, composing, and repeated events are left alone", () => {
|
||||
assert.equal(
|
||||
isCloseWindowShortcut(chord({ defaultPrevented: true }), true),
|
||||
false,
|
||||
);
|
||||
assert.equal(
|
||||
isCloseWindowShortcut(chord({ isComposing: true }), true),
|
||||
false,
|
||||
);
|
||||
assert.equal(isCloseWindowShortcut(chord({ repeat: true }), true), false);
|
||||
});
|
||||
55
desktop/src/app/useCloseWindowShortcut.ts
Normal file
55
desktop/src/app/useCloseWindowShortcut.ts
Normal file
@@ -0,0 +1,55 @@
|
||||
import * as React from "react";
|
||||
import { isTauri } from "@tauri-apps/api/core";
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
|
||||
import { isMacPlatform } from "@/shared/lib/platform";
|
||||
|
||||
type CloseWindowChord = Pick<
|
||||
KeyboardEvent,
|
||||
| "altKey"
|
||||
| "code"
|
||||
| "ctrlKey"
|
||||
| "defaultPrevented"
|
||||
| "isComposing"
|
||||
| "metaKey"
|
||||
| "repeat"
|
||||
| "shiftKey"
|
||||
>;
|
||||
|
||||
export function isCloseWindowShortcut(
|
||||
event: CloseWindowChord,
|
||||
isMac: boolean,
|
||||
): boolean {
|
||||
return (
|
||||
isMac &&
|
||||
event.code === "KeyW" &&
|
||||
event.metaKey &&
|
||||
!event.ctrlKey &&
|
||||
!event.altKey &&
|
||||
!event.shiftKey &&
|
||||
!event.defaultPrevented &&
|
||||
!event.isComposing &&
|
||||
!event.repeat
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Restores the standard macOS Cmd+W behavior without reclaiming the native
|
||||
* menu accelerator. Buzz Term handles the chord first in capture phase while
|
||||
* it owns input; otherwise this bubble-phase listener closes the current
|
||||
* window. The main window's Rust close handler turns that into hide-to-tray.
|
||||
*/
|
||||
export function useCloseWindowShortcut() {
|
||||
React.useEffect(() => {
|
||||
if (!isTauri()) return;
|
||||
|
||||
function handleKeyDown(event: KeyboardEvent) {
|
||||
if (!isCloseWindowShortcut(event, isMacPlatform())) return;
|
||||
event.preventDefault();
|
||||
void getCurrentWindow().close();
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
return () => window.removeEventListener("keydown", handleKeyDown);
|
||||
}, []);
|
||||
}
|
||||
@@ -3,11 +3,16 @@ import { getCurrentWebview } from "@tauri-apps/api/webview";
|
||||
|
||||
import { hasPrimaryShortcutModifier } from "@/shared/lib/platform";
|
||||
|
||||
/**
|
||||
* Cmd +/- scales the real root font-size, so every rem in the app — text,
|
||||
* spacing, widths, radii — zooms together. The Font size preference is a
|
||||
* separate, text-only dial layered on top (see `styles/globals/typography.css`).
|
||||
*/
|
||||
const BASE_FONT_SIZE_PX = 16;
|
||||
const DEFAULT_ZOOM_FACTOR = 1;
|
||||
const MIN_ZOOM_FACTOR = 0.75;
|
||||
const MAX_ZOOM_FACTOR = 1.5;
|
||||
const ZOOM_STEP = 0.1;
|
||||
const BASE_FONT_SIZE_PX = 16;
|
||||
const TEXT_SCALE_STORAGE_KEY = "buzz:text-scale";
|
||||
|
||||
type ZoomAction = "increase" | "decrease" | "reset";
|
||||
@@ -75,14 +80,20 @@ function readStoredZoomFactor() {
|
||||
return Math.min(Math.max(parsed, MIN_ZOOM_FACTOR), MAX_ZOOM_FACTOR);
|
||||
}
|
||||
|
||||
function applyRootZoom(zoomFactor: number) {
|
||||
document.documentElement.style.fontSize =
|
||||
zoomFactor === DEFAULT_ZOOM_FACTOR
|
||||
? ""
|
||||
: `${BASE_FONT_SIZE_PX * zoomFactor}px`;
|
||||
}
|
||||
|
||||
function applyTextScale(zoomFactor: number) {
|
||||
applyRootZoom(zoomFactor);
|
||||
if (zoomFactor === DEFAULT_ZOOM_FACTOR) {
|
||||
document.documentElement.style.fontSize = "";
|
||||
window.localStorage.removeItem(TEXT_SCALE_STORAGE_KEY);
|
||||
return;
|
||||
}
|
||||
|
||||
document.documentElement.style.fontSize = `${BASE_FONT_SIZE_PX * zoomFactor}px`;
|
||||
window.localStorage.setItem(TEXT_SCALE_STORAGE_KEY, String(zoomFactor));
|
||||
}
|
||||
|
||||
@@ -96,7 +107,8 @@ export function useWebviewZoomShortcuts() {
|
||||
zoomFactorRef.current = storedZoomFactor;
|
||||
applyTextScale(storedZoomFactor);
|
||||
|
||||
// Keep the webview coordinate system stable; only text should scale.
|
||||
// Pin the native webview zoom so the rem root is the only zoom dial and
|
||||
// window/coordinate math stays stable.
|
||||
void webview.setZoom(DEFAULT_ZOOM_FACTOR).catch((error) => {
|
||||
console.error("Failed to reset webview zoom", error);
|
||||
});
|
||||
@@ -120,9 +132,21 @@ export function useWebviewZoomShortcuts() {
|
||||
applyTextScale(nextZoomFactor);
|
||||
}
|
||||
|
||||
function handleStorage(event: StorageEvent) {
|
||||
if (event.key !== TEXT_SCALE_STORAGE_KEY && event.key !== null) {
|
||||
return;
|
||||
}
|
||||
|
||||
const storedZoomFactor = readStoredZoomFactor();
|
||||
zoomFactorRef.current = storedZoomFactor;
|
||||
applyRootZoom(storedZoomFactor);
|
||||
}
|
||||
|
||||
window.addEventListener("keydown", handleKeyDown);
|
||||
window.addEventListener("storage", handleStorage);
|
||||
return () => {
|
||||
window.removeEventListener("keydown", handleKeyDown);
|
||||
window.removeEventListener("storage", handleStorage);
|
||||
};
|
||||
}, []);
|
||||
}
|
||||
|
||||
5
desktop/src/features-manifest.d.ts
vendored
5
desktop/src/features-manifest.d.ts
vendored
@@ -2,3 +2,8 @@ declare module "@features-manifest" {
|
||||
const manifest: import("@/shared/features/types").FeaturesManifest;
|
||||
export default manifest;
|
||||
}
|
||||
|
||||
declare module "@model-capabilities-manifest" {
|
||||
const manifest: unknown;
|
||||
export default manifest;
|
||||
}
|
||||
|
||||
@@ -160,8 +160,12 @@ with a TypeScript lookup table or an id comparison in a component.
|
||||
computer, including files, accounts, and connected tools"; remote names "the
|
||||
server it runs on, including any accounts and tools available there" —
|
||||
deliberately *not* the owner's files, which aren't theirs to describe on a
|
||||
host they don't own. **An unknown location falls back to the local wording —
|
||||
never hedge with "computer or server".** A remote host requires an
|
||||
host they don't own. **For a persona-linked deployed agent, the profile Edit
|
||||
dialog seeds access from the exact clicked instance and saves access through
|
||||
`update_managed_agent`; persona behavior remains the definition default, but
|
||||
must never bypass the instance command's stop, persist, publish, and restart
|
||||
boundary.** An unknown location falls back to the local wording — never hedge
|
||||
with "computer or server". A remote host requires an
|
||||
installed `buzz-backend-*` provider, and without one `WhereToRunSection`
|
||||
never renders, so "server" would name a concept the owner has never been
|
||||
shown; when it *is* remote they picked that host from the selector
|
||||
@@ -171,7 +175,81 @@ with a TypeScript lookup table or an id comparison in a component.
|
||||
`getAgentAccessOwnerOnly()` is true, every managed agent's access control is
|
||||
locked to owner-only, including provider-backed agents. A provider backend
|
||||
does not prove remote execution and must never create a policy carve-out.
|
||||
12. **A remote deploy's permission policy has two truths: desired and applied.**
|
||||
12. **Shared instructions must be reviewable byte-for-byte.** Agent definitions
|
||||
execute their `system_prompt` verbatim, so catalog and snapshot review
|
||||
surfaces render the literal prompt, never the chat Markdown projection
|
||||
(which can conceal spoilers, link destinations, and image sources). Reject
|
||||
Unicode default-ignorable, bidirectional-formatting, and non-layout control
|
||||
characters at both the untrusted catalog parser and the Rust persistence /
|
||||
import boundary. Do not silently strip them: rejection keeps the reviewed
|
||||
string identical to the executed string. New sharing paths must reuse the
|
||||
same validation before they persist or activate a definition.
|
||||
13. **Profile runtime sections render only reported agent data.** Missing
|
||||
runtime, model, status, command, MCP, advanced, or diagnostics values stay
|
||||
absent in every build mode. Do not fill profile or agent-panel gaps with
|
||||
development/staging examples, preview controls, or synthetic configuration;
|
||||
those values can be mistaken for the viewed agent's real configuration.
|
||||
Configuration rows show the effective value regardless of whether it came
|
||||
from an explicit choice, global default, config file, or runtime override.
|
||||
Do not add provenance lines, shadowed/struck-through values, pre-start
|
||||
placeholders, or whole-section dimming; use an em dash for an unknown value.
|
||||
Info, activity, agent-configuration, and model-setting rows use the same bare
|
||||
16px leading-icon treatment as agent management actions. Keep semantic icons
|
||||
visible in profile variants and do not wrap them in background shapes. An
|
||||
owned agent profile is entry-point invariant: opening the same deployed
|
||||
agent from Agents, a DM, or a channel must expose the same actions, tabs,
|
||||
fields, and profile-wide activity selection. Caller context may control the
|
||||
panel shell or return navigation, but must not filter or replace profile
|
||||
content.
|
||||
14. **Thinking effort has two surfaces: a local-only WRITE control and a
|
||||
read-only two-facts DISPLAY.** The write control is `EffortPickerField`
|
||||
(`ui/EffortPickerField.tsx`), a self-contained section component mounted in
|
||||
`AgentInstanceEditDialog` beside the Model block. It is direct-write, not
|
||||
part of the frozen `UpdateManagedAgentInput` shape: each selection calls
|
||||
`persistAgentEffortLevel` and invalidates the config-surface query, mirroring
|
||||
the `setManagedAgentAutoRestart` standalone-setter precedent. Its gating and
|
||||
option compute live in the pure helper `ui/effortPicker.ts`
|
||||
(`effortPickerState`): the picker renders only when
|
||||
`agent.backend.type === "local"` **AND** a `thought_level` `effortConfigId`
|
||||
has been discovered from the running session (absent pre-first-session and
|
||||
for runtimes/models without effort support). Local-only is load-bearing, not
|
||||
cosmetic — the Rust command rejects non-local backends because remote effort
|
||||
is set at deploy time via `policy_env`. Because it reads its inputs from the
|
||||
config surface the dialog already fetches (`useAgentConfigSurface`) and owns
|
||||
its own mutation, it does **not** thread new props through the over-1000-line
|
||||
dialog (see rule 11): keep effort state inside the section component, never
|
||||
as dialog-level props. The read-only display is the `thinkingEffort`
|
||||
normalized field rendered by `AgentConfigPanel` via `NormalizedRow`, which
|
||||
already shows both facts — `field.value` (canonical, the effort the next
|
||||
spawn will launch with) and, when a running ACP session differs,
|
||||
`field.overriddenValue` struck through (the live session's current effort).
|
||||
No component owns "configured vs current" logic; the reader's canonical tier
|
||||
ordering feeds both facts. Do not add a second effort write path or restate
|
||||
the two-facts logic in a component.
|
||||
|
||||
**Cut invariant — live mid-conversation effort machinery was deliberately
|
||||
removed.** Effort is spawn-scoped only: the worker holds one `startup_effort`
|
||||
read from `BUZZ_ACP_EFFORT_LEVEL` and applies it once at session creation
|
||||
(`apply_startup_effort` in `buzz-acp/src/pool.rs`); there is no pool-level
|
||||
effort authority, no live effort switching, and no effort-ack frame. Do not
|
||||
reintroduce a live effort-switch RPC, a pool effort field, or a
|
||||
mid-conversation effort control without a plan ruling. The archived live-effort
|
||||
machinery lives on `archive/claude-config-gaps-live-effort` for reference only.
|
||||
|
||||
15. **Owner-only builds constrain managed runtimes, not relay-agent mentions.**
|
||||
The compiled owner-only capability applies when Desktop starts or deploys a
|
||||
managed agent. Independently operated relay agents with NIP-OA ownership
|
||||
remain eligible in every build when their verified owner's signed
|
||||
`respond_to` policy admits the viewer and relay membership includes the
|
||||
target channel. Marked builds require that verified owner coordinate but do
|
||||
not require it to equal the viewer; OSS builds retain compatibility with
|
||||
self-authored legacy directory records. Keep native discovery and send-time
|
||||
revalidation fail closed on invalid ownership or managed policy evidence,
|
||||
and on missing membership or directory evidence; do not add a cross-owner
|
||||
clamp to either mention path. Local `agents-data-changed` events
|
||||
refresh only local persona/team/managed-agent caches; they must never
|
||||
invalidate the remote relay directory.
|
||||
16. **A remote deploy's permission policy has two truths: desired and applied.**
|
||||
**The desired policy is live-resolved through a four-tier chain**
|
||||
(`resolve_effective_permission_policy`), highest precedence first: the
|
||||
per-instance record override (`source: agent`) → the linked definition's
|
||||
@@ -234,6 +312,20 @@ with a TypeScript lookup table or an id comparison in a component.
|
||||
- `lib/agentAccessWarning.test.mjs` — every mode × run-location copy variant
|
||||
plus both resolvers, including unknown-reads-as-local and
|
||||
blank-`runOn`-is-not-a-provider.
|
||||
- `lib/personaCatalogRelay.test.mjs` and
|
||||
`ui/personaCatalogOwnerLabel.test.mjs` — reject invisible definition text
|
||||
and keep Markdown concealment syntax literal in the review surface.
|
||||
- `../profile/ui/UserProfileRuntimeContent.test.mjs` — profile runtime panels
|
||||
cannot reintroduce build-mode previews or synthetic fallback controls.
|
||||
- `desktop/tests/e2e/profile.spec.ts` — the owned-agent parity flow compares
|
||||
every profile tab when opened from Agents and from the agent's DM.
|
||||
- `ui/AgentConfigPanelPresentation.test.mjs` — shared profile/agent config rows
|
||||
show only effective values, with an em dash for unknown values.
|
||||
- `ui/effortPicker.test.mjs` — `effortPickerState` gating (local + discovered
|
||||
`effortConfigId` renders; provider backend or missing configId hides) and
|
||||
option/preselect compute, plus `effortSelectionToPersistedValue` sentinel →
|
||||
null. This is where the v4 provider regression is pinned: the write control
|
||||
must never render for a provider backend.
|
||||
- `desktop/tests/e2e/onboarding-agent-defaults.spec.ts` — onboarding behavior
|
||||
acceptance coverage for readiness, failure states, defaults, session-draft
|
||||
restoration, zero-write Skip, Next save failure/retry, navigation, and
|
||||
@@ -261,6 +353,8 @@ with a TypeScript lookup table or an id comparison in a component.
|
||||
editable control, and the drift row shown for remote+drift with both values
|
||||
and "redeploy required"; hidden when applied equals desired, when applied is
|
||||
absent, for local agents, and for a provider-selected-but-undeployed agent.
|
||||
- Rust: `definition_validation` and inbound persona tests pin the shared
|
||||
Unicode/control-character policy at local, import, publish, and sync gates.
|
||||
|
||||
## Keep this file true
|
||||
|
||||
|
||||
491
desktop/src/features/agents/acpRuntimesQuery.test.mjs
Normal file
491
desktop/src/features/agents/acpRuntimesQuery.test.mjs
Normal file
@@ -0,0 +1,491 @@
|
||||
/**
|
||||
* Regression tests for the cheap/forced ACP runtime discovery split.
|
||||
*
|
||||
* Two IMPORTANT correctness contracts from the review of the split:
|
||||
*
|
||||
* (1) refreshAcpRuntimes() must never coalesce onto an in-flight *cheap*
|
||||
* request. React Query's fetchQuery deduplicates on the shared query key,
|
||||
* so a cheap fetch already running would otherwise satisfy the forced
|
||||
* refresh with cached data and the forced { force: true } probe would
|
||||
* never run. The fix runs the forced probe on a separate key, writes its
|
||||
* result into the shared cache, then cancels the in-flight cheap query.
|
||||
* This test holds a cheap request pending, fires refreshAcpRuntimes(),
|
||||
* resolves the cheap request, and asserts a distinct { force: true } native
|
||||
* call happened and the shared cache holds the forced result.
|
||||
*
|
||||
* (2) useAcpRuntimesQueryForced({ forceOnMount: false }) must consume shared
|
||||
* state without mounting its own force effect. Onboarding mounts the hook
|
||||
* once as the surface owner (forceOnMount default true) and once per row
|
||||
* (forceOnMount false); entering the surface must cause exactly one forced
|
||||
* native call before any user action.
|
||||
*
|
||||
* The Tauri IPC bridge is stubbed at globalThis.__TAURI_INTERNALS__.invoke so
|
||||
* discoverAcpRuntimes() calls are intercepted by command name and the { force }
|
||||
* payload is observed directly (same pattern as
|
||||
* useLoadArchivedObserverEvents.test.mjs).
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { afterEach, describe, it } from "node:test";
|
||||
|
||||
// ── Minimal DOM shim (subset used by other mounted-hook tests) ────────────────
|
||||
|
||||
function installDOMShim() {
|
||||
if (globalThis.document) return;
|
||||
|
||||
class MinimalEventTarget {
|
||||
constructor() {
|
||||
this._listeners = {};
|
||||
}
|
||||
addEventListener(type, fn) {
|
||||
this._listeners[type] ??= [];
|
||||
this._listeners[type].push(fn);
|
||||
}
|
||||
removeEventListener(type, fn) {
|
||||
this._listeners[type] = (this._listeners[type] ?? []).filter(
|
||||
(f) => f !== fn,
|
||||
);
|
||||
}
|
||||
dispatchEvent(e) {
|
||||
for (const fn of this._listeners[e.type] ?? []) fn(e);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
class MinimalNode extends MinimalEventTarget {
|
||||
constructor(tagName) {
|
||||
super();
|
||||
this.tagName = tagName;
|
||||
this.children = [];
|
||||
this.childNodes = [];
|
||||
this.style = {};
|
||||
this.nodeType = 1;
|
||||
this.parentNode = null;
|
||||
}
|
||||
get ownerDocument() {
|
||||
return globalThis.document;
|
||||
}
|
||||
get firstChild() {
|
||||
return this.children[0] ?? null;
|
||||
}
|
||||
get nextSibling() {
|
||||
return null;
|
||||
}
|
||||
appendChild(child) {
|
||||
this.children.push(child);
|
||||
this.childNodes.push(child);
|
||||
child.parentNode = this;
|
||||
return child;
|
||||
}
|
||||
removeChild(child) {
|
||||
this.children = this.children.filter((c) => c !== child);
|
||||
this.childNodes = this.childNodes.filter((c) => c !== child);
|
||||
return child;
|
||||
}
|
||||
insertBefore(newNode, refNode) {
|
||||
if (!refNode) return this.appendChild(newNode);
|
||||
const i = this.children.indexOf(refNode);
|
||||
if (i < 0) return this.appendChild(newNode);
|
||||
this.children.splice(i, 0, newNode);
|
||||
this.childNodes.splice(i, 0, newNode);
|
||||
newNode.parentNode = this;
|
||||
return newNode;
|
||||
}
|
||||
contains(node) {
|
||||
if (!node) return false;
|
||||
return this === node || this.children.some((c) => c?.contains?.(node));
|
||||
}
|
||||
}
|
||||
|
||||
class MinimalDocument extends MinimalEventTarget {
|
||||
constructor() {
|
||||
super();
|
||||
this.nodeType = 9;
|
||||
}
|
||||
createElement(tagName) {
|
||||
return new MinimalNode(tagName);
|
||||
}
|
||||
createTextNode(value) {
|
||||
const n = new MinimalNode("#text");
|
||||
n.nodeValue = value;
|
||||
n.nodeType = 3;
|
||||
return n;
|
||||
}
|
||||
createComment(value) {
|
||||
const n = new MinimalNode("#comment");
|
||||
n.nodeValue = value;
|
||||
n.nodeType = 8;
|
||||
return n;
|
||||
}
|
||||
get body() {
|
||||
if (!this._body) this._body = this.createElement("body");
|
||||
return this._body;
|
||||
}
|
||||
get activeElement() {
|
||||
return null;
|
||||
}
|
||||
contains(node) {
|
||||
return node != null;
|
||||
}
|
||||
}
|
||||
|
||||
globalThis.document = new MinimalDocument();
|
||||
globalThis.HTMLElement = MinimalNode;
|
||||
globalThis.HTMLIFrameElement = MinimalNode;
|
||||
globalThis.IS_REACT_ACT_ENVIRONMENT = true;
|
||||
process.env.IS_REACT_ACT_ENVIRONMENT = "true";
|
||||
if (typeof globalThis.window === "undefined") {
|
||||
Object.defineProperty(globalThis, "window", {
|
||||
value: globalThis,
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
if (!Object.getOwnPropertyDescriptor(globalThis, "navigator")?.value) {
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
value: { userAgent: "node" },
|
||||
configurable: true,
|
||||
});
|
||||
}
|
||||
globalThis.MutationObserver = class {
|
||||
observe() {}
|
||||
disconnect() {}
|
||||
takeRecords() {
|
||||
return [];
|
||||
}
|
||||
};
|
||||
globalThis.requestAnimationFrame = (fn) => setTimeout(fn, 0);
|
||||
}
|
||||
|
||||
installDOMShim();
|
||||
|
||||
// ── Tauri IPC interceptor ─────────────────────────────────────────────────────
|
||||
|
||||
/** @type {Array<{ command: string, args: unknown }>} */
|
||||
const calls = [];
|
||||
/** @type {(args: unknown) => Promise<unknown>} */
|
||||
let discoverHandler = () => Promise.resolve([]);
|
||||
|
||||
globalThis.__TAURI_INTERNALS__ = {
|
||||
invoke: (command, args) => {
|
||||
calls.push({ command, args });
|
||||
if (command === "discover_acp_providers") return discoverHandler(args);
|
||||
return Promise.reject(new Error(`unmocked Tauri command: ${command}`));
|
||||
},
|
||||
transformCallback: () => Math.random(),
|
||||
};
|
||||
|
||||
// ── Production imports (after shim + IPC stub) ────────────────────────────────
|
||||
|
||||
import React from "react";
|
||||
import { createRoot } from "react-dom/client";
|
||||
import { act } from "react";
|
||||
import { QueryClient } from "@tanstack/react-query";
|
||||
import { QueryClientProvider } from "@tanstack/react-query";
|
||||
|
||||
import {
|
||||
acpRuntimesQueryKey,
|
||||
refreshAcpRuntimes,
|
||||
useAcpRuntimesQueryForced,
|
||||
} from "./acpRuntimesQuery.ts";
|
||||
import { discoverAcpRuntimes } from "@/shared/api/tauriAcpDiscovery.ts";
|
||||
|
||||
// ── Wire-shape helper ─────────────────────────────────────────────────────────
|
||||
|
||||
/** A raw discover_acp_providers row (snake_case wire shape). */
|
||||
function rawEntry(id, authStatusValue) {
|
||||
return {
|
||||
id,
|
||||
label: id,
|
||||
avatar_url: "",
|
||||
availability: "available",
|
||||
command: id,
|
||||
binary_path: `/usr/bin/${id}`,
|
||||
default_args: [],
|
||||
mcp_command: null,
|
||||
install_hint: "",
|
||||
install_instructions_url: "",
|
||||
can_auto_install: false,
|
||||
underlying_cli_path: null,
|
||||
node_required: false,
|
||||
auth_status: { status: authStatusValue },
|
||||
source: "builtin",
|
||||
};
|
||||
}
|
||||
|
||||
function makeQueryClient() {
|
||||
return new QueryClient({ defaultOptions: { queries: { retry: false } } });
|
||||
}
|
||||
|
||||
/** A promise plus its resolver, for holding a request pending. */
|
||||
function deferred() {
|
||||
let resolve;
|
||||
const promise = new Promise((r) => {
|
||||
resolve = r;
|
||||
});
|
||||
return { promise, resolve };
|
||||
}
|
||||
|
||||
afterEach(() => {
|
||||
calls.length = 0;
|
||||
discoverHandler = () => Promise.resolve([]);
|
||||
});
|
||||
|
||||
describe("refreshAcpRuntimes cannot dedup onto an in-flight cheap request", () => {
|
||||
it("runs a distinct force:true probe and writes it into the shared cache", async () => {
|
||||
const queryClient = makeQueryClient();
|
||||
queryClient.mount();
|
||||
|
||||
// 1. A cheap request (force:false) is in flight and held pending.
|
||||
const cheap = deferred();
|
||||
discoverHandler = (args) => {
|
||||
if (args?.force === false) return cheap.promise;
|
||||
// 2. The forced request resolves immediately with distinct data.
|
||||
return Promise.resolve([rawEntry("codex", "logged_in")]);
|
||||
};
|
||||
|
||||
// Start the cheap fetch through the real cheap query path and leave pending.
|
||||
const cheapFetch = queryClient.fetchQuery({
|
||||
queryKey: acpRuntimesQueryKey,
|
||||
queryFn: () => discoverAcpRuntimes(),
|
||||
staleTime: 30 * 60_000,
|
||||
});
|
||||
await new Promise((r) => setImmediate(r));
|
||||
|
||||
// 3. Forced refresh fires while the cheap fetch is still pending.
|
||||
const forced = await refreshAcpRuntimes(queryClient);
|
||||
|
||||
// 4. Resolve the cheap request afterward; it must not be what the caller got.
|
||||
cheap.resolve([rawEntry("codex", "unknown")]);
|
||||
await cheapFetch.catch(() => {});
|
||||
|
||||
const forceCalls = calls.filter(
|
||||
(c) => c.command === "discover_acp_providers" && c.args?.force === true,
|
||||
);
|
||||
assert.equal(
|
||||
forceCalls.length,
|
||||
1,
|
||||
"exactly one forced native probe must have run",
|
||||
);
|
||||
assert.equal(forced[0]?.authStatus.status, "logged_in");
|
||||
assert.equal(
|
||||
queryClient.getQueryData(acpRuntimesQueryKey)?.[0]?.authStatus.status,
|
||||
"logged_in",
|
||||
"shared cache must hold the forced result, not the later cheap one",
|
||||
);
|
||||
|
||||
queryClient.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAcpRuntimesQueryForced surfaces forced-probe failures", () => {
|
||||
it("projects a mount-time forced rejection into error with no unhandled rejection", async () => {
|
||||
const unhandled = [];
|
||||
const onUnhandled = (err) => unhandled.push(err);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
|
||||
const queryClient = makeQueryClient();
|
||||
discoverHandler = (args) =>
|
||||
args?.force === true
|
||||
? Promise.reject(new Error("forced probe failed"))
|
||||
: Promise.resolve([]);
|
||||
|
||||
let latest = null;
|
||||
function Consumer() {
|
||||
latest = useAcpRuntimesQueryForced();
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
React.createElement(Consumer),
|
||||
),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
assert.equal(
|
||||
latest?.error instanceof Error && latest.error.message,
|
||||
"forced probe failed",
|
||||
"mount-time forced rejection must surface as the hook's error",
|
||||
);
|
||||
assert.equal(
|
||||
latest?.isError,
|
||||
true,
|
||||
"isError must reflect the forced failure",
|
||||
);
|
||||
|
||||
// Drain the microtask queue so any stray rejection would have fired.
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
assert.deepEqual(
|
||||
unhandled,
|
||||
[],
|
||||
"no unhandled rejection may escape the fire-and-forget mount force",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
|
||||
it("surfaces an explicit-refresh rejection and clears it on the next success", async () => {
|
||||
const unhandled = [];
|
||||
const onUnhandled = (err) => unhandled.push(err);
|
||||
process.on("unhandledRejection", onUnhandled);
|
||||
|
||||
const queryClient = makeQueryClient();
|
||||
let failForced = true;
|
||||
discoverHandler = (args) => {
|
||||
if (args?.force !== true) return Promise.resolve([]);
|
||||
return failForced
|
||||
? Promise.reject(new Error("refresh failed"))
|
||||
: Promise.resolve([rawEntry("codex", "logged_in")]);
|
||||
};
|
||||
|
||||
let latest = null;
|
||||
function Consumer() {
|
||||
// forceOnMount:false so the only forced probe is the explicit refresh.
|
||||
latest = useAcpRuntimesQueryForced({ forceOnMount: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
React.createElement(Consumer),
|
||||
),
|
||||
);
|
||||
});
|
||||
|
||||
// Explicit refresh (button/polling shape): void-called, must not reject.
|
||||
await act(async () => {
|
||||
void latest.forceRefresh();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
assert.equal(
|
||||
latest?.error instanceof Error && latest.error.message,
|
||||
"refresh failed",
|
||||
"explicit-refresh rejection must surface as the hook's error",
|
||||
);
|
||||
|
||||
// A subsequent successful refresh clears the error and delivers data.
|
||||
failForced = false;
|
||||
await act(async () => {
|
||||
void latest.forceRefresh();
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
assert.equal(
|
||||
latest?.error,
|
||||
null,
|
||||
"a later successful refresh clears the error",
|
||||
);
|
||||
assert.equal(
|
||||
queryClient.getQueryData(acpRuntimesQueryKey)?.[0]?.authStatus.status,
|
||||
"logged_in",
|
||||
"successful refresh writes the fresh catalog into the shared cache",
|
||||
);
|
||||
|
||||
await new Promise((r) => setTimeout(r, 10));
|
||||
process.off("unhandledRejection", onUnhandled);
|
||||
assert.deepEqual(
|
||||
unhandled,
|
||||
[],
|
||||
"no unhandled rejection may escape a void forceRefresh() call",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
describe("useAcpRuntimesQueryForced force-on-mount ownership", () => {
|
||||
it("a later-mounted row does not fire a second forced probe", async () => {
|
||||
const queryClient = makeQueryClient();
|
||||
discoverHandler = () => Promise.resolve([rawEntry("codex", "logged_in")]);
|
||||
|
||||
// Onboarding's real sequence: the surface owner mounts and forces discovery;
|
||||
// once its result renders, per-runtime rows mount. A row that shared the
|
||||
// owner's default force-on-mount would fire a *second*, sequential forced
|
||||
// probe (forced-key dedup cannot collapse it — the owner's fetch is already
|
||||
// idle). Rows pass forceOnMount:false to consume shared state only.
|
||||
function Owner() {
|
||||
useAcpRuntimesQueryForced();
|
||||
return null;
|
||||
}
|
||||
function Row() {
|
||||
useAcpRuntimesQueryForced({ forceOnMount: false });
|
||||
return null;
|
||||
}
|
||||
|
||||
const container = document.createElement("div");
|
||||
const root = createRoot(container);
|
||||
|
||||
// 1. Owner mounts and forces once; let the probe settle.
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
React.createElement(Owner),
|
||||
),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
const afterOwner = calls.filter(
|
||||
(c) => c.command === "discover_acp_providers" && c.args?.force === true,
|
||||
).length;
|
||||
assert.equal(afterOwner, 1, "owner mount must force exactly once");
|
||||
|
||||
// 2. Rows mount after the owner's result settled; they must not re-probe.
|
||||
await act(async () => {
|
||||
root.render(
|
||||
React.createElement(
|
||||
QueryClientProvider,
|
||||
{ client: queryClient },
|
||||
React.createElement(Owner),
|
||||
React.createElement(Row),
|
||||
React.createElement(Row),
|
||||
React.createElement(Row),
|
||||
),
|
||||
);
|
||||
});
|
||||
await act(async () => {
|
||||
await new Promise((r) => setTimeout(r, 50));
|
||||
});
|
||||
|
||||
const forceCalls = calls.filter(
|
||||
(c) => c.command === "discover_acp_providers" && c.args?.force === true,
|
||||
);
|
||||
assert.equal(
|
||||
forceCalls.length,
|
||||
1,
|
||||
"later-mounted rows must not trigger a second forced probe",
|
||||
);
|
||||
const cheapCalls = calls.filter(
|
||||
(c) => c.command === "discover_acp_providers" && c.args?.force === false,
|
||||
);
|
||||
assert.equal(
|
||||
cheapCalls.length,
|
||||
0,
|
||||
"the forced hook must never fire a cheap fetch (enabled: false observer)",
|
||||
);
|
||||
|
||||
await act(async () => {
|
||||
root.unmount();
|
||||
});
|
||||
});
|
||||
});
|
||||
135
desktop/src/features/agents/acpRuntimesQuery.ts
Normal file
135
desktop/src/features/agents/acpRuntimesQuery.ts
Normal file
@@ -0,0 +1,135 @@
|
||||
import * as React from "react";
|
||||
import { useQuery, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { discoverAcpRuntimes } from "@/shared/api/tauriAcpDiscovery";
|
||||
|
||||
/**
|
||||
* Shared React Query key for the ACP runtime catalog. Every consumer (cheap or
|
||||
* forced) reads and writes this one entry, so a forced refresh updates the same
|
||||
* cache the hot-path `useAcpRuntimesQuery` renders from.
|
||||
*/
|
||||
export const acpRuntimesQueryKey = ["acp-runtimes"] as const;
|
||||
|
||||
/**
|
||||
* Separate key for the forced (full re-discovery) fetch. Forced refresh runs on
|
||||
* *this* key, never the shared cheap key, so React Query's `fetchQuery` can
|
||||
* never deduplicate a forced probe onto an in-flight cheap request for the
|
||||
* shared key. The forced result is then written into the shared cache
|
||||
* deliberately (see `refreshAcpRuntimes`).
|
||||
*/
|
||||
export const acpRuntimesForcedQueryKey = ["acp-runtimes", "forced"] as const;
|
||||
|
||||
/**
|
||||
* Run a forced (full re-discovery) refresh and write the result into the shared
|
||||
* runtime-catalog cache.
|
||||
*
|
||||
* This is the only path that pays the expensive discovery pipeline (cache
|
||||
* clear, PATH re-fetch, CLI auth probes). Surfaces that need fresh state call
|
||||
* it deliberately: Settings/onboarding on open and on their refresh buttons,
|
||||
* and the connect/install/save/delete mutations in `onSettled`. A bare
|
||||
* `invalidateQueries` would only re-run the cheap query path and never
|
||||
* re-probe, so the freshly-changed auth/catalog state would not be reflected.
|
||||
*
|
||||
* The forced fetch runs on its own key so it can never coalesce onto an
|
||||
* in-flight *cheap* request for the shared key (which would satisfy the caller
|
||||
* with cached availability and never run the `{ force: true }` probe). Its
|
||||
* result is then written into the shared cache with `setQueryData` so hot
|
||||
* surfaces rendering `useAcpRuntimesQuery` re-render with the fresh catalog.
|
||||
* Concurrent forced callers still dedup on the forced key; the backend
|
||||
* coalesces overlapping forced runs as a second layer.
|
||||
*/
|
||||
export async function refreshAcpRuntimes(
|
||||
queryClient: ReturnType<typeof useQueryClient>,
|
||||
) {
|
||||
try {
|
||||
const result = await queryClient.fetchQuery({
|
||||
queryKey: acpRuntimesForcedQueryKey,
|
||||
queryFn: () => discoverAcpRuntimes({ force: true }),
|
||||
staleTime: 0,
|
||||
gcTime: 0,
|
||||
});
|
||||
queryClient.setQueryData(acpRuntimesQueryKey, result);
|
||||
// A hot-surface cheap fetch may already be in flight on the shared key; cancel
|
||||
// it so its (older, cached) result cannot land after and clobber the fresh
|
||||
// forced catalog we just wrote.
|
||||
await queryClient.cancelQueries({ queryKey: acpRuntimesQueryKey });
|
||||
return result;
|
||||
} catch {
|
||||
// The forced probe rejected. `fetchQuery` has already recorded the error in
|
||||
// the forced key's query state, where `useAcpRuntimesQueryForced` projects
|
||||
// it into the hook's returned `error`/`isError`. Swallow the rejection here
|
||||
// — at the single source — so the many fire-and-forget callers (mount,
|
||||
// sign-in polling, refresh buttons, and the four mutation `onSettled`
|
||||
// paths) can keep `void refreshAcpRuntimes(...)` without ever leaking an
|
||||
// unhandled rejection, and a new call site can never reintroduce one. The
|
||||
// shared cache is left untouched so consumers keep the last good catalog
|
||||
// alongside the surfaced error.
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* ACP runtimes query for surfaces that need fresh auth/version state: Settings
|
||||
* harness panels and onboarding.
|
||||
*
|
||||
* It reads the shared runtime catalog (`enabled: false`, so it never fires its
|
||||
* own cheap fetch — the forced probe below is the only fetcher) and re-renders
|
||||
* whenever `refreshAcpRuntimes` writes a fresh catalog into that cache. Loading
|
||||
* *and error* state are taken from a disabled observer on the forced key, so
|
||||
* refresh buttons and the onboarding spinner reflect the forced probe and a
|
||||
* failed probe surfaces as `error`/`isError` rather than a silent empty
|
||||
* catalog. `forceRefresh` drives explicit refresh buttons and sign-in
|
||||
* polling.
|
||||
*
|
||||
* `forceOnMount` (default `true`) is the surface owner's one force-on-mount.
|
||||
* Child rows that share the same surface must pass `forceOnMount: false`: they
|
||||
* consume the shared query state and the `forceRefresh` callback, but must not
|
||||
* mount a *second* force effect. Each mounted force effect is a distinct forced
|
||||
* probe, so an owner + N rows would otherwise re-run the 20–65s pipeline N+1
|
||||
* times on entry (and race the catalog to a later state before the owner's
|
||||
* first result renders).
|
||||
*/
|
||||
export function useAcpRuntimesQueryForced(options?: {
|
||||
enabled?: boolean;
|
||||
forceOnMount?: boolean;
|
||||
}) {
|
||||
const enabled = options?.enabled ?? true;
|
||||
const forceOnMount = options?.forceOnMount ?? true;
|
||||
const queryClient = useQueryClient();
|
||||
const query = useQuery({
|
||||
queryKey: acpRuntimesQueryKey,
|
||||
queryFn: () => discoverAcpRuntimes(),
|
||||
staleTime: 30 * 60_000,
|
||||
// Read-only observer: the forced refresh is the fetcher for these surfaces,
|
||||
// so this must never fire a cheap fetch (which would race and could
|
||||
// overwrite the fresh forced result with cached data).
|
||||
enabled: false,
|
||||
});
|
||||
// Read-only observer on the forced key so the hook surfaces the forced
|
||||
// probe's fetching *and error* state. `refreshAcpRuntimes` runs the fetch
|
||||
// imperatively via `fetchQuery`; this disabled observer never fetches itself
|
||||
// but reflects that query's state, so a rejected forced probe becomes a
|
||||
// visible `error`/`isError` instead of an unhandled rejection with a silent
|
||||
// empty/stale catalog.
|
||||
const forcedQuery = useQuery({
|
||||
queryKey: acpRuntimesForcedQueryKey,
|
||||
queryFn: () => discoverAcpRuntimes({ force: true }),
|
||||
enabled: false,
|
||||
});
|
||||
const forceRefresh = React.useCallback(
|
||||
() => refreshAcpRuntimes(queryClient),
|
||||
[queryClient],
|
||||
);
|
||||
React.useEffect(() => {
|
||||
if (enabled && forceOnMount) void forceRefresh();
|
||||
}, [enabled, forceOnMount, forceRefresh]);
|
||||
const isFetching = query.isFetching || forcedQuery.isFetching;
|
||||
return {
|
||||
...query,
|
||||
error: forcedQuery.error ?? query.error,
|
||||
isError: forcedQuery.isError || query.isError,
|
||||
isFetching,
|
||||
isLoading: isFetching && query.data === undefined,
|
||||
forceRefresh,
|
||||
};
|
||||
}
|
||||
@@ -12,6 +12,7 @@ import {
|
||||
restoreActiveAgentTurnsForCommunity,
|
||||
clearSavedCommunitySnapshot,
|
||||
clearActiveTurnsForAgent,
|
||||
createActiveAgentTurnsObserverListener,
|
||||
} from "./activeAgentTurnsStore.ts";
|
||||
import {
|
||||
injectObserverEventsForE2E,
|
||||
@@ -1625,6 +1626,82 @@ describe("observer → active-turns bridge sync", () => {
|
||||
);
|
||||
});
|
||||
|
||||
it("publishes only newly admitted events for the changed agent", () => {
|
||||
const updates = [];
|
||||
const unsubscribeObserver = subscribeAgentObserverStore((update) => {
|
||||
updates.push(update);
|
||||
});
|
||||
const retained = makeEvent({ seq: 1, kind: "turn_started" });
|
||||
const admitted = makeEvent({
|
||||
seq: 2,
|
||||
kind: "acp_write",
|
||||
timestamp: "2024-01-01T00:00:01Z",
|
||||
});
|
||||
|
||||
injectObserverEventsForE2E(AGENT, [retained]);
|
||||
injectObserverEventsForE2E(AGENT, [retained, admitted]);
|
||||
unsubscribeObserver();
|
||||
|
||||
assert.equal(updates.length, 2);
|
||||
assert.equal(updates[1].agentPubkey, AGENT);
|
||||
assert.deepEqual(
|
||||
updates[1].events.map((event) => event.seq),
|
||||
[2],
|
||||
"the publication must omit retained and duplicate history",
|
||||
);
|
||||
});
|
||||
|
||||
it("steady-state listener processes the changed active agent only", () => {
|
||||
const listener = createActiveAgentTurnsObserverListener([
|
||||
{ pubkey: AGENT, status: "deployed" },
|
||||
{ pubkey: AGENT_2, status: "stopped" },
|
||||
]);
|
||||
|
||||
listener({
|
||||
agentPubkey: AGENT,
|
||||
events: [makeEvent({ seq: 1, turnId: "active-turn" })],
|
||||
});
|
||||
listener({
|
||||
agentPubkey: AGENT_2,
|
||||
events: [
|
||||
makeEvent({
|
||||
seq: 1,
|
||||
turnId: "stopped-turn",
|
||||
channelId: "stopped-channel",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(getActiveTurnsForAgent(AGENT).length, 1);
|
||||
assert.equal(
|
||||
getActiveTurnsForAgent(AGENT_2).length,
|
||||
0,
|
||||
"an unrelated stopped agent update must not enter turn state",
|
||||
);
|
||||
});
|
||||
|
||||
it("incremental terminal update clears a hydrated turn without replay", () => {
|
||||
injectObserverEventsForE2E(AGENT, [
|
||||
makeEvent({ seq: 1, kind: "turn_started" }),
|
||||
]);
|
||||
syncActiveAgentTurnsFromObserver(bridgeAgents);
|
||||
assert.equal(getActiveTurnsForAgent(AGENT).length, 1);
|
||||
|
||||
const listener = createActiveAgentTurnsObserverListener(bridgeAgents);
|
||||
listener({
|
||||
agentPubkey: AGENT,
|
||||
events: [
|
||||
makeEvent({
|
||||
seq: 2,
|
||||
kind: "turn_completed",
|
||||
timestamp: "2024-01-01T00:00:05Z",
|
||||
}),
|
||||
],
|
||||
});
|
||||
|
||||
assert.equal(getActiveTurnsForAgent(AGENT).length, 0);
|
||||
});
|
||||
|
||||
it("publishes one observer update for a batch while preserving outcomes", () => {
|
||||
let observerNotifications = 0;
|
||||
const unsubscribeObserver = subscribeAgentObserverStore(() => {
|
||||
|
||||
@@ -4,6 +4,7 @@ import {
|
||||
subscribeAgentObserverStore,
|
||||
getAgentObserverSnapshot,
|
||||
compareObserverEvents,
|
||||
type AgentObserverStoreUpdate,
|
||||
} from "@/features/agents/observerRelayStore";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import {
|
||||
@@ -631,19 +632,40 @@ export function syncActiveAgentTurnsFromObserver(
|
||||
}
|
||||
|
||||
/**
|
||||
* Bridge hook: processes observer events into the active-turns store.
|
||||
* Should be called by a parent component that has access to the observer events.
|
||||
* Build the steady-state observer listener once per agent-list revision. Observer
|
||||
* publications carry only newly admitted events for one agent, so this callback
|
||||
* does not revisit unrelated agents or their retained journals.
|
||||
*/
|
||||
export function createActiveAgentTurnsObserverListener(
|
||||
agents: readonly { pubkey: string; status: string }[],
|
||||
): (update?: AgentObserverStoreUpdate) => void {
|
||||
const activeAgentPubkeys = new Set(
|
||||
agents
|
||||
.filter(
|
||||
(agent) => agent.status === "running" || agent.status === "deployed",
|
||||
)
|
||||
.map((agent) => normalizePubkey(agent.pubkey)),
|
||||
);
|
||||
|
||||
return (update?: AgentObserverStoreUpdate) => {
|
||||
if (
|
||||
!update ||
|
||||
!activeAgentPubkeys.has(normalizePubkey(update.agentPubkey))
|
||||
) {
|
||||
return;
|
||||
}
|
||||
syncAgentTurnsFromEvents(update.agentPubkey, [...update.events]);
|
||||
};
|
||||
}
|
||||
|
||||
export function useActiveAgentTurnsBridge(
|
||||
agents: readonly { pubkey: string; status: string }[],
|
||||
) {
|
||||
React.useEffect(() => {
|
||||
function syncAll() {
|
||||
syncActiveAgentTurnsFromObserver(agents);
|
||||
}
|
||||
|
||||
syncAll();
|
||||
return subscribeAgentObserverStore(syncAll);
|
||||
syncActiveAgentTurnsFromObserver(agents);
|
||||
return subscribeAgentObserverStore(
|
||||
createActiveAgentTurnsObserverListener(agents),
|
||||
);
|
||||
}, [agents]);
|
||||
}
|
||||
|
||||
|
||||
@@ -57,13 +57,13 @@ test("agents: skips fresh focus refetch", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("agents: refetches genuinely stale data on focus", async () => {
|
||||
test("agents: does not refetch stale data on focus", async () => {
|
||||
assert.equal(
|
||||
await focusRefetchCount({
|
||||
ageMs: agentsFocusRefetchPolicy.staleTime + 1,
|
||||
policy: agentsFocusRefetchPolicy,
|
||||
}),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -77,12 +77,12 @@ test("managed-agent-log: skips focus refetch when data is fresher than one poll
|
||||
);
|
||||
});
|
||||
|
||||
test("managed-agent-log: refetches on focus when data is older than one poll tick", async () => {
|
||||
test("managed-agent-log: does not refetch stale data on focus", async () => {
|
||||
assert.equal(
|
||||
await focusRefetchCount({
|
||||
ageMs: managedAgentLogFocusRefetchPolicy.staleTime + 1,
|
||||
policy: managedAgentLogFocusRefetchPolicy,
|
||||
}),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -25,7 +25,6 @@ import {
|
||||
createManagedAgent,
|
||||
deleteManagedAgent,
|
||||
deleteCustomHarness,
|
||||
discoverAcpRuntimes,
|
||||
discoverBackendProviders,
|
||||
discoverGitBashPrerequisite,
|
||||
discoverManagedAgentPrereqs,
|
||||
@@ -43,6 +42,7 @@ import {
|
||||
updateManagedAgent,
|
||||
} from "@/shared/api/tauri";
|
||||
import type { HarnessDefinitionInput } from "@/shared/api/tauri";
|
||||
import { discoverAcpRuntimes } from "@/shared/api/tauriAcpDiscovery";
|
||||
import {
|
||||
setManagedAgentAutoRestart,
|
||||
setManagedAgentStartOnAppLaunch,
|
||||
@@ -50,6 +50,11 @@ import {
|
||||
stopManagedAgent,
|
||||
} from "@/shared/api/tauriManagedAgents";
|
||||
import { bootstrapManagedAgentRuntimePairs } from "@/features/agents/managedAgentRuntimeHooks";
|
||||
import {
|
||||
acpRuntimesQueryKey,
|
||||
refreshAcpRuntimes,
|
||||
} from "@/features/agents/acpRuntimesQuery";
|
||||
export { useAcpRuntimesQueryForced } from "@/features/agents/acpRuntimesQuery";
|
||||
import {
|
||||
createPersona,
|
||||
deletePersona,
|
||||
@@ -111,19 +116,18 @@ export const MANAGED_AGENT_LOG_FOCUS_STALE_TIME_MS = 30_000;
|
||||
/** Focus-refetch policy for relay-agent and managed-agent queries; consumed by focusRefetchPolicy.test.mjs. */
|
||||
export const agentsFocusRefetchPolicy = {
|
||||
staleTime: AGENTS_FOCUS_STALE_TIME_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnWindowFocus: false,
|
||||
} as const;
|
||||
|
||||
/** Focus-refetch policy for the managed-agent-log query; consumed by focusRefetchPolicy.test.mjs. */
|
||||
export const managedAgentLogFocusRefetchPolicy = {
|
||||
staleTime: MANAGED_AGENT_LOG_FOCUS_STALE_TIME_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnWindowFocus: false,
|
||||
} as const;
|
||||
|
||||
export const relayAgentsQueryKey = ["relay-agents"] as const;
|
||||
export const managedAgentsQueryKey = ["managed-agents"] as const;
|
||||
export const personasQueryKey = ["personas"] as const;
|
||||
export const acpRuntimesQueryKey = ["acp-runtimes"] as const;
|
||||
export const acpAuthMethodsQueryKey = ["acp-auth-methods"] as const;
|
||||
export const managedAgentPrereqsQueryKey = ["managed-agent-prereqs"] as const;
|
||||
export const backendProvidersQueryKey = ["backend-providers"] as const;
|
||||
@@ -199,12 +203,26 @@ function invalidateManagedAgentQueriesInBackground(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Discover the ACP runtime catalog.
|
||||
*
|
||||
* This always serves the **cheap** backend path: the last cached runtime
|
||||
* availability + auth statuses, no process spawns, low-millisecond. Hot
|
||||
* surfaces (channel switch, composer, member bar) render from cache — a
|
||||
* 30-minute `staleTime` keeps channel switches from re-triggering discovery.
|
||||
*
|
||||
* Fresh auth/version state (Settings, onboarding sign-in, post-mutation) comes
|
||||
* from `refreshAcpRuntimes`, which runs the expensive forced path explicitly
|
||||
* and writes the result into this same cache. Keeping the query's own
|
||||
* `queryFn` cheap guarantees an automatic staleness refetch never re-runs the
|
||||
* probe pipeline.
|
||||
*/
|
||||
export function useAcpRuntimesQuery(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
enabled: options?.enabled ?? true,
|
||||
queryKey: acpRuntimesQueryKey,
|
||||
queryFn: discoverAcpRuntimes,
|
||||
staleTime: 60_000,
|
||||
queryFn: () => discoverAcpRuntimes(),
|
||||
staleTime: 30 * 60_000,
|
||||
});
|
||||
}
|
||||
|
||||
@@ -238,7 +256,7 @@ export function useConnectAcpRuntimeMutation() {
|
||||
mutationFn: (input: { runtimeId: string; methodId: string }) =>
|
||||
connectAcpRuntime(input.runtimeId, input.methodId),
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey });
|
||||
void refreshAcpRuntimes(queryClient);
|
||||
void queryClient.invalidateQueries({ queryKey: acpAuthMethodsQueryKey });
|
||||
void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
|
||||
},
|
||||
@@ -250,7 +268,7 @@ export function useInstallAcpRuntimeMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (runtimeId: string) => installAcpRuntime(runtimeId),
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey });
|
||||
void refreshAcpRuntimes(queryClient);
|
||||
void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
|
||||
},
|
||||
});
|
||||
@@ -267,7 +285,7 @@ export function useSaveCustomHarnessMutation() {
|
||||
originalId?: string;
|
||||
}) => saveCustomHarness(definition, originalId),
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey });
|
||||
void refreshAcpRuntimes(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -277,7 +295,7 @@ export function useDeleteCustomHarnessMutation() {
|
||||
return useMutation({
|
||||
mutationFn: (id: string) => deleteCustomHarness(id),
|
||||
onSettled: () => {
|
||||
void queryClient.invalidateQueries({ queryKey: acpRuntimesQueryKey });
|
||||
void refreshAcpRuntimes(queryClient);
|
||||
},
|
||||
});
|
||||
}
|
||||
@@ -341,14 +359,10 @@ export function useRelayAgentsQuery(options?: { enabled?: boolean }) {
|
||||
return useQuery({
|
||||
queryKey: relayAgentsQueryKey,
|
||||
queryFn: listRelayAgents,
|
||||
// Relay agent profiles (kind:10100) are near-static and the backing
|
||||
// `list_relay_agents` command is an unfiltered relay query for the whole
|
||||
// profile set — mounted on ~13 always-live surfaces (channel screen,
|
||||
// members bar, mentions, sidebar, profile popovers), so a tight interval
|
||||
// re-pulls the full set app-wide. This poll is also the ONLY refresh path:
|
||||
// the `agents-data-changed` event fires only for local persona/team/managed
|
||||
// reconcile (kinds PERSONA/TEAM/MANAGED_AGENT), never for kind:10100. So we
|
||||
// keep polling but at a relaxed cadence and pause it while backgrounded.
|
||||
// Relay agent discovery is scoped to the viewer's relay-signed channel
|
||||
// memberships, then resolves exact agent/profile/policy coordinates in
|
||||
// protocol-sized batches. Polling remains the only refresh path for remote
|
||||
// changes, so keep it relaxed and pause while backgrounded.
|
||||
refetchInterval,
|
||||
enabled: options?.enabled,
|
||||
...agentsFocusRefetchPolicy,
|
||||
@@ -553,7 +567,24 @@ export function useStartManagedAgentMutation() {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
return useMutation({
|
||||
mutationFn: (pubkey: string) => startManagedAgent(pubkey),
|
||||
// Accepts a bare pubkey, or an object carrying the tenant scope a
|
||||
// long-lived callback captured before its first await (the backend
|
||||
// fails closed on a mid-flight community/identity switch).
|
||||
mutationFn: (
|
||||
input:
|
||||
| string
|
||||
| {
|
||||
pubkey: string;
|
||||
expectedRelayUrl?: string;
|
||||
expectedSignerPubkey?: string;
|
||||
},
|
||||
) =>
|
||||
typeof input === "string"
|
||||
? startManagedAgent(input)
|
||||
: startManagedAgent(input.pubkey, {
|
||||
expectedRelayUrl: input.expectedRelayUrl,
|
||||
expectedSignerPubkey: input.expectedSignerPubkey,
|
||||
}),
|
||||
onSuccess: (updated) => {
|
||||
queryClient.setQueryData<ManagedAgent[]>(
|
||||
managedAgentsQueryKey,
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
import { channelAgentMembers } from "@/shared/lib/rosterDerivations";
|
||||
|
||||
/**
|
||||
* Pure merge behind `useKnownAgentPubkeys`: managed agents ∪ relay agents,
|
||||
@@ -62,10 +63,11 @@ export function mergeChannelKnownAgentPubkeys(
|
||||
relayAgents: readonly { pubkey: string }[] | undefined,
|
||||
): ReadonlySet<string> {
|
||||
const pubkeys = new Set(mergeKnownAgentPubkeys(managedAgents, relayAgents));
|
||||
for (const member of channelMembers ?? []) {
|
||||
if (member.role === "bot" || member.isAgent) {
|
||||
pubkeys.add(normalizePubkey(member.pubkey));
|
||||
}
|
||||
// Identity-cached agent subset: avoids walking the full roster per call.
|
||||
for (const member of channelMembers
|
||||
? channelAgentMembers(channelMembers)
|
||||
: []) {
|
||||
pubkeys.add(normalizePubkey(member.pubkey));
|
||||
}
|
||||
return pubkeys;
|
||||
}
|
||||
|
||||
@@ -3,7 +3,9 @@ import test from "node:test";
|
||||
|
||||
import {
|
||||
coalesceAgentAutocompleteCandidates,
|
||||
filterAdmittedMentionPubkeys,
|
||||
filterCachedAgentSuggestions,
|
||||
getAgentMentionAdmission,
|
||||
getMentionableAgentPubkeys,
|
||||
getSharedChannelIds,
|
||||
isAgentIdentityInAllowedList,
|
||||
@@ -64,11 +66,13 @@ test("relayAgentIsSharedWithUser: accepts shared anyone agents and rejects unsha
|
||||
assert.equal(
|
||||
relayAgentIsSharedWithUser(
|
||||
{
|
||||
ownerPubkey: OTHER_OWNER_PUBKEY,
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
channelIds: ["general"],
|
||||
},
|
||||
sharedChannelIds,
|
||||
CURRENT_PUBKEY,
|
||||
),
|
||||
false,
|
||||
);
|
||||
@@ -81,6 +85,22 @@ test("relayAgentIsSharedWithUser: accepts shared anyone agents and rejects unsha
|
||||
);
|
||||
});
|
||||
|
||||
test("relayAgentIsSharedWithUser: accepts verified same-owner agents across machines", () => {
|
||||
assert.equal(
|
||||
relayAgentIsSharedWithUser(
|
||||
{
|
||||
ownerPubkey: CURRENT_PUBKEY.toUpperCase(),
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
channelIds: ["general"],
|
||||
},
|
||||
new Set(["general"]),
|
||||
CURRENT_PUBKEY,
|
||||
),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("relayAgentIsSharedWithUser: accepts allowlist agents for the current user", () => {
|
||||
const sharedChannelIds = new Set(["general"]);
|
||||
|
||||
@@ -307,7 +327,7 @@ test("shouldHideAgentFromMentions: hides member agents with an explicit not-invo
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldHideAgentFromMentions: shows member agents with unknown invocability (not in directory)", () => {
|
||||
test("shouldHideAgentFromMentions: hides member agents without an affirmative directory grant", () => {
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
isAgent: true,
|
||||
@@ -316,6 +336,74 @@ test("shouldHideAgentFromMentions: shows member agents with unknown invocability
|
||||
mentionableAgentPubkeys: new Set(),
|
||||
directoryAgentPubkeys: new Set(),
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldHideAgentFromMentions: hides unknown member agents while directories load", () => {
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
isAgent: true,
|
||||
isMember: true,
|
||||
pubkey: PUB_A,
|
||||
mentionableAgentPubkeys: new Set(),
|
||||
directoryAgentPubkeys: new Set(),
|
||||
directoryReady: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldHideAgentFromMentions: hides mentionable member agents while directories load", () => {
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
isAgent: true,
|
||||
isMember: true,
|
||||
pubkey: PUB_A,
|
||||
mentionableAgentPubkeys: new Set([PUB_A]),
|
||||
directoryAgentPubkeys: new Set(),
|
||||
directoryReady: false,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldHideAgentFromMentions: shows non-agent members while directories load", () => {
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
isAgent: false,
|
||||
isMember: true,
|
||||
pubkey: PUB_A,
|
||||
mentionableAgentPubkeys: new Set(),
|
||||
directoryAgentPubkeys: new Set([PUB_A]),
|
||||
directoryReady: false,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldHideAgentFromMentions: hides unknown member agents after empty directories settle", () => {
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
isAgent: true,
|
||||
isMember: true,
|
||||
pubkey: PUB_A,
|
||||
mentionableAgentPubkeys: new Set(),
|
||||
directoryAgentPubkeys: new Set(),
|
||||
directoryReady: true,
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
test("shouldHideAgentFromMentions: shows authorized agents without managed-owner policy", () => {
|
||||
assert.equal(
|
||||
shouldHideAgentFromMentions({
|
||||
isAgent: true,
|
||||
pubkey: PUB_A,
|
||||
mentionableAgentPubkeys: new Set([PUB_A]),
|
||||
directoryReady: true,
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
@@ -336,6 +424,47 @@ test("shouldHideAgentFromMentions: normalizes the pubkey before lookup", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("getAgentMentionAdmission: authorized relay agents are independent of owner", () => {
|
||||
const common = {
|
||||
isAgent: true,
|
||||
pubkey: PUB_A,
|
||||
mentionableAgentPubkeys: new Set([PUB_A]),
|
||||
directoryReady: true,
|
||||
};
|
||||
|
||||
assert.equal(getAgentMentionAdmission(common), "allow");
|
||||
assert.equal(
|
||||
getAgentMentionAdmission({
|
||||
...common,
|
||||
mentionableAgentPubkeys: new Set(),
|
||||
}),
|
||||
"deny",
|
||||
);
|
||||
});
|
||||
|
||||
test("getAgentMentionAdmission: unresolved directory state stays unknown", () => {
|
||||
assert.equal(
|
||||
getAgentMentionAdmission({
|
||||
isAgent: true,
|
||||
pubkey: PUB_A,
|
||||
mentionableAgentPubkeys: new Set([PUB_A]),
|
||||
directoryReady: false,
|
||||
}),
|
||||
"unknown",
|
||||
);
|
||||
});
|
||||
|
||||
test("filterAdmittedMentionPubkeys: rechecks agent admission without dropping people", () => {
|
||||
assert.deepEqual(
|
||||
filterAdmittedMentionPubkeys(
|
||||
[PUB_A, PUB_B, PUB_C],
|
||||
new Set([PUB_A, PUB_B]),
|
||||
new Set([PUB_B]),
|
||||
),
|
||||
[PUB_B, PUB_C],
|
||||
);
|
||||
});
|
||||
|
||||
test("coalesceAgentAutocompleteCandidates: keeps agents with the same persona id distinct", () => {
|
||||
const first = makeAgent({ pubkey: PUB_A, personaId: "pinky" });
|
||||
const second = makeAgent({
|
||||
|
||||
@@ -10,7 +10,10 @@ export function getSharedChannelIds(channels: readonly Channel[] | undefined) {
|
||||
}
|
||||
|
||||
export function relayAgentIsSharedWithUser(
|
||||
agent: Pick<RelayAgent, "channelIds" | "respondTo" | "respondToAllowlist">,
|
||||
agent: Pick<
|
||||
RelayAgent,
|
||||
"channelIds" | "ownerPubkey" | "respondTo" | "respondToAllowlist"
|
||||
>,
|
||||
sharedChannelIds: ReadonlySet<string>,
|
||||
currentPubkey?: string | null,
|
||||
) {
|
||||
@@ -18,6 +21,14 @@ export function relayAgentIsSharedWithUser(
|
||||
? normalizePubkey(currentPubkey)
|
||||
: null;
|
||||
|
||||
if (
|
||||
agent.respondTo === "owner-only" &&
|
||||
normalizedCurrentPubkey &&
|
||||
agent.ownerPubkey
|
||||
) {
|
||||
return normalizePubkey(agent.ownerPubkey) === normalizedCurrentPubkey;
|
||||
}
|
||||
|
||||
if (agent.respondTo === "allowlist" && normalizedCurrentPubkey) {
|
||||
return agent.respondToAllowlist
|
||||
.map((pubkey) => normalizePubkey(pubkey))
|
||||
@@ -31,7 +42,10 @@ export function relayAgentIsSharedWithUser(
|
||||
}
|
||||
|
||||
export function relayAgentCanRespondInChannel(
|
||||
agent: Pick<RelayAgent, "channelIds" | "respondTo" | "respondToAllowlist">,
|
||||
agent: Pick<
|
||||
RelayAgent,
|
||||
"channelIds" | "ownerPubkey" | "respondTo" | "respondToAllowlist"
|
||||
>,
|
||||
channelId: string,
|
||||
currentPubkey?: string | null,
|
||||
) {
|
||||
@@ -92,37 +106,113 @@ export function isAgentIdentityInAllowedList(
|
||||
);
|
||||
}
|
||||
|
||||
export function shouldHideAgentFromMentions({
|
||||
export type AgentMentionAdmission = "allow" | "deny" | "unknown";
|
||||
|
||||
export function getAgentMentionAdmission({
|
||||
isAgent,
|
||||
isMember,
|
||||
pubkey,
|
||||
mentionableAgentPubkeys,
|
||||
directoryAgentPubkeys,
|
||||
directoryReady,
|
||||
}: {
|
||||
isAgent: boolean;
|
||||
isMember: boolean;
|
||||
pubkey: string;
|
||||
mentionableAgentPubkeys: ReadonlySet<string>;
|
||||
directoryAgentPubkeys: ReadonlySet<string>;
|
||||
directoryReady: boolean;
|
||||
}): AgentMentionAdmission {
|
||||
if (!isAgent) return "allow";
|
||||
if (!directoryReady) return "unknown";
|
||||
|
||||
return mentionableAgentPubkeys.has(normalizePubkey(pubkey))
|
||||
? "allow"
|
||||
: "deny";
|
||||
}
|
||||
|
||||
export function shouldHideAgentFromMentions({
|
||||
isAgent,
|
||||
pubkey,
|
||||
mentionableAgentPubkeys,
|
||||
directoryReady = true,
|
||||
}: {
|
||||
isAgent: boolean;
|
||||
pubkey: string;
|
||||
mentionableAgentPubkeys: ReadonlySet<string>;
|
||||
directoryReady?: boolean;
|
||||
}) {
|
||||
if (!isAgent) return false;
|
||||
const normalized = normalizePubkey(pubkey);
|
||||
// Invocable => always show.
|
||||
if (mentionableAgentPubkeys.has(normalized)) return false;
|
||||
// Non-member, non-invocable => hide (preserves prior behavior).
|
||||
if (!isMember) return true;
|
||||
// Member (Option B): hide only when we have an explicit not-invocable
|
||||
// signal — a relay directory (kind:10100) entry that excludes us.
|
||||
// Unknown invocability (not in directory) => show.
|
||||
//
|
||||
// NOTE: this assumes `directoryAgentPubkeys` and `mentionableAgentPubkeys`
|
||||
// share the same source query (`relayAgentsQuery.data`), so directory
|
||||
// presence without membership in `mentionableAgentPubkeys` is a real
|
||||
// explicit-exclusion signal. If a future change sources the directory set
|
||||
// from a different query, an agent that's directory-present but whose
|
||||
// mentionability is still loading could be hidden prematurely — keep the
|
||||
// two sets derived from the same query.
|
||||
return directoryAgentPubkeys.has(normalized);
|
||||
return (
|
||||
getAgentMentionAdmission({
|
||||
isAgent,
|
||||
pubkey,
|
||||
mentionableAgentPubkeys,
|
||||
directoryReady,
|
||||
}) !== "allow"
|
||||
);
|
||||
}
|
||||
|
||||
export function getAgentIdentityPubkeys({
|
||||
managedAgentPubkeys,
|
||||
relayAgents,
|
||||
members,
|
||||
profileIsAgent,
|
||||
}: {
|
||||
managedAgentPubkeys: ReadonlySet<string>;
|
||||
relayAgents: readonly { pubkey: string }[];
|
||||
members: readonly {
|
||||
pubkey: string;
|
||||
isAgent?: boolean;
|
||||
role?: string | null;
|
||||
}[];
|
||||
profileIsAgent: (pubkey: string) => boolean;
|
||||
}) {
|
||||
return new Set([
|
||||
...managedAgentPubkeys,
|
||||
...relayAgents.map(({ pubkey }) => normalizePubkey(pubkey)),
|
||||
...members
|
||||
.filter(
|
||||
(member) =>
|
||||
member.isAgent === true ||
|
||||
member.role === "bot" ||
|
||||
profileIsAgent(normalizePubkey(member.pubkey)),
|
||||
)
|
||||
.map(({ pubkey }) => normalizePubkey(pubkey)),
|
||||
]);
|
||||
}
|
||||
|
||||
export function getAdmittedAgentPubkeys(
|
||||
candidates: readonly { pubkey?: string; isAgent?: boolean }[],
|
||||
) {
|
||||
return new Set(
|
||||
candidates.flatMap((candidate) =>
|
||||
candidate.isAgent && candidate.pubkey
|
||||
? [normalizePubkey(candidate.pubkey)]
|
||||
: [],
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
export function rememberSelectedAgentPubkeys(
|
||||
target: Set<string>,
|
||||
selected: readonly { pubkey?: string; isAgent?: boolean }[],
|
||||
selectionIsAgent: boolean,
|
||||
) {
|
||||
for (const candidate of selected) {
|
||||
if (candidate.pubkey && (selectionIsAgent || candidate.isAgent === true)) {
|
||||
target.add(normalizePubkey(candidate.pubkey));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export function filterAdmittedMentionPubkeys(
|
||||
pubkeys: readonly string[],
|
||||
agentIdentityPubkeys: ReadonlySet<string>,
|
||||
admittedAgentPubkeys: ReadonlySet<string>,
|
||||
) {
|
||||
return pubkeys.filter((pubkey) => {
|
||||
const normalized = normalizePubkey(pubkey);
|
||||
return (
|
||||
!agentIdentityPubkeys.has(normalized) ||
|
||||
admittedAgentPubkeys.has(normalized)
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
export function isAgentMentionChannelType(type?: string | null) {
|
||||
|
||||
@@ -56,3 +56,187 @@ test("resolveAgentCardModelLabel — non-inherited agent with a blank resolved m
|
||||
});
|
||||
assert.equal(label, "Default model (claude-sonnet)");
|
||||
});
|
||||
|
||||
// Databricks registry integration
|
||||
import { formatAgentModelLabel } from "./formatAgentModelLabel.ts";
|
||||
|
||||
test("formatAgentModelLabel — Databricks aliases reuse canonical labels", () => {
|
||||
assert.equal(
|
||||
formatAgentModelLabel("goose-gpt-5-6-sol", "databricks_v2"),
|
||||
"GPT-5.6 Sol",
|
||||
);
|
||||
assert.equal(
|
||||
formatAgentModelLabel("goose-claude-fable-5", "databricks_v2"),
|
||||
"Claude Fable 5",
|
||||
);
|
||||
assert.equal(
|
||||
formatAgentModelLabel("goose-claude-opus-4-8", "databricks_v2"),
|
||||
"Claude Opus 4.8",
|
||||
);
|
||||
assert.equal(
|
||||
formatAgentModelLabel("goose-claude-opus-5", "databricks_v2"),
|
||||
"Claude Opus 5",
|
||||
);
|
||||
assert.equal(
|
||||
formatAgentModelLabel("goose-claude-sonnet-5", "databricks_v2"),
|
||||
"Claude Sonnet 5",
|
||||
);
|
||||
assert.equal(
|
||||
formatAgentModelLabel("goose-kimi-k3", "databricks_v2"),
|
||||
"Kimi K3",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveModelLabel — Databricks alias labels stay provider-scoped", () => {
|
||||
assert.equal(
|
||||
resolveModelLabel("goose-gpt-5-6-sol", null, "openai"),
|
||||
"goose-gpt-5-6-sol",
|
||||
);
|
||||
});
|
||||
|
||||
test("formatAgentModelLabel — bare family IDs remain raw", () => {
|
||||
assert.equal(formatAgentModelLabel("gpt-5"), "gpt-5");
|
||||
});
|
||||
|
||||
test("formatAgentModelLabel — known Databricks managed ID returns curated name", () => {
|
||||
assert.equal(formatAgentModelLabel("databricks-gpt-5-5"), "GPT-5.5");
|
||||
assert.equal(
|
||||
formatAgentModelLabel("databricks-claude-opus-4-7"),
|
||||
"Claude Opus 4.7",
|
||||
);
|
||||
assert.equal(
|
||||
formatAgentModelLabel("databricks-claude-opus-5"),
|
||||
"Claude Opus 5",
|
||||
);
|
||||
assert.equal(
|
||||
formatAgentModelLabel("databricks-claude-sonnet-5"),
|
||||
"Claude Sonnet 5",
|
||||
);
|
||||
assert.equal(formatAgentModelLabel("databricks-kimi-k3"), "Kimi K3");
|
||||
});
|
||||
|
||||
test("formatAgentModelLabel — unknown custom Databricks ID returns raw ID unchanged", () => {
|
||||
assert.equal(
|
||||
formatAgentModelLabel("databricks-team-2025-01"),
|
||||
"databricks-team-2025-01",
|
||||
);
|
||||
});
|
||||
|
||||
test("formatAgentModelLabel — non-Databricks ID returns raw ID unchanged", () => {
|
||||
assert.equal(formatAgentModelLabel("claude-sonnet-4-7"), "claude-sonnet-4-7");
|
||||
assert.equal(formatAgentModelLabel("gpt-4o"), "gpt-4o");
|
||||
});
|
||||
|
||||
test("formatAgentModelLabel — null or empty returns Auto", () => {
|
||||
assert.equal(formatAgentModelLabel(null), "Auto");
|
||||
assert.equal(formatAgentModelLabel(""), "Auto");
|
||||
assert.equal(formatAgentModelLabel(" "), "Auto");
|
||||
});
|
||||
|
||||
import { resolveModelLabel } from "./formatAgentModelLabel.ts";
|
||||
|
||||
test("resolveModelLabel — echoed id name falls through to the registry (real discovery shape)", () => {
|
||||
// buzz-agent's Databricks discovery emits {id, name: id}; the echoed name
|
||||
// carries no display info, so the registry tier must curate the label.
|
||||
assert.equal(
|
||||
resolveModelLabel(
|
||||
"databricks-gpt-5-5",
|
||||
"databricks-gpt-5-5",
|
||||
"databricks_v2",
|
||||
),
|
||||
"GPT-5.5",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveModelLabel — echoed id name for an unknown id stays raw", () => {
|
||||
assert.equal(
|
||||
resolveModelLabel(
|
||||
"databricks-team-2025-01",
|
||||
"databricks-team-2025-01",
|
||||
"databricks_v2",
|
||||
),
|
||||
"databricks-team-2025-01",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveModelLabel — a discovered name distinct from the id still wins tier 1", () => {
|
||||
// The "(default catalog)" suffixed name (and any genuinely distinct name) is
|
||||
// authoritative and must not be discarded by the echo-equality check.
|
||||
assert.equal(
|
||||
resolveModelLabel(
|
||||
"databricks-gpt-5-5",
|
||||
"GPT-5.5 (default catalog)",
|
||||
"databricks_v2",
|
||||
),
|
||||
"GPT-5.5 (default catalog)",
|
||||
);
|
||||
});
|
||||
|
||||
test("resolveAgentCardModelLabel — known Databricks defaultModel with databricks_v2 provider renders curated name in default label", () => {
|
||||
const label = resolveAgentCardModelLabel({
|
||||
agent: undefined,
|
||||
personaModel: null,
|
||||
provider: "databricks_v2",
|
||||
defaultModel: "databricks-gpt-5-5",
|
||||
});
|
||||
assert.equal(label, "Default model (GPT-5.5)");
|
||||
});
|
||||
|
||||
test("resolveAgentCardModelLabel — unknown custom Databricks defaultModel renders raw ID in default label", () => {
|
||||
const label = resolveAgentCardModelLabel({
|
||||
agent: undefined,
|
||||
personaModel: null,
|
||||
defaultModel: "databricks-team-2025-01",
|
||||
});
|
||||
assert.equal(label, "Default model (databricks-team-2025-01)");
|
||||
});
|
||||
|
||||
test("resolveAgentCardModelLabel — known Databricks agent model renders curated name", () => {
|
||||
const label = resolveAgentCardModelLabel({
|
||||
agent: { modelSource: "definition", model: "databricks-gpt-oss-120b" },
|
||||
personaModel: null,
|
||||
defaultModel: "something-else",
|
||||
});
|
||||
assert.equal(label, "GPT OSS 120B");
|
||||
});
|
||||
|
||||
// P2 regression: provider-scoped default label — Databricks ID under openai/anthropic must render raw
|
||||
test("resolveAgentCardModelLabel — openai agent inheriting a Databricks-named default renders raw ID", () => {
|
||||
const label = resolveAgentCardModelLabel({
|
||||
agent: { modelSource: "global", model: null, provider: "openai" },
|
||||
personaModel: null,
|
||||
provider: "openai",
|
||||
defaultModel: "databricks-gpt-5-5",
|
||||
});
|
||||
assert.equal(label, "Default model (databricks-gpt-5-5)");
|
||||
});
|
||||
|
||||
test("resolveAgentCardModelLabel — anthropic agent inheriting a Databricks-named default renders raw ID", () => {
|
||||
const label = resolveAgentCardModelLabel({
|
||||
agent: { modelSource: "global", model: null, provider: "anthropic" },
|
||||
personaModel: null,
|
||||
provider: "anthropic",
|
||||
defaultModel: "databricks-gpt-5-5",
|
||||
});
|
||||
assert.equal(label, "Default model (databricks-gpt-5-5)");
|
||||
});
|
||||
|
||||
test("resolveAgentCardModelLabel — databricks_v2 agent inheriting a Databricks-named default renders curated name", () => {
|
||||
const label = resolveAgentCardModelLabel({
|
||||
agent: { modelSource: "global", model: null, provider: "databricks_v2" },
|
||||
personaModel: null,
|
||||
provider: "databricks_v2",
|
||||
defaultModel: "databricks-gpt-5-5",
|
||||
});
|
||||
assert.equal(label, "Default model (GPT-5.5)");
|
||||
});
|
||||
|
||||
test("resolveAgentCardModelLabel — unspawned openai persona with Databricks-named default renders raw ID", () => {
|
||||
const label = resolveAgentCardModelLabel({
|
||||
agent: undefined,
|
||||
personaModel: null,
|
||||
provider: "openai",
|
||||
defaultModel: "databricks-gpt-5-5",
|
||||
});
|
||||
assert.equal(label, "Default model (databricks-gpt-5-5)");
|
||||
});
|
||||
|
||||
@@ -1,4 +1,7 @@
|
||||
import { formatAgentModelLabel } from "./formatAgentModelLabel";
|
||||
import {
|
||||
formatAgentModelLabel,
|
||||
resolveModelLabel,
|
||||
} from "./formatAgentModelLabel";
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
|
||||
/**
|
||||
@@ -19,26 +22,33 @@ import type { ManagedAgent } from "@/shared/api/types";
|
||||
* than falling through to "inherited" for lack of an instance.
|
||||
*/
|
||||
export function resolveAgentCardModelLabel(input: {
|
||||
agent: Pick<ManagedAgent, "modelSource" | "model"> | undefined;
|
||||
agent: Pick<ManagedAgent, "modelSource" | "model" | "provider"> | undefined;
|
||||
personaModel: string | null | undefined;
|
||||
/** Inference provider for the persona/agent — threads provider-qualified label lookup. */
|
||||
provider?: string | null | undefined;
|
||||
defaultModel: string;
|
||||
}): string {
|
||||
if (input.agent) {
|
||||
const isInherited =
|
||||
!input.agent.modelSource || input.agent.modelSource === "global";
|
||||
if (isInherited) {
|
||||
return formatDefaultModelLabel(input.defaultModel);
|
||||
return formatDefaultModelLabel(input.defaultModel, input.agent.provider);
|
||||
}
|
||||
return input.agent.model?.trim()
|
||||
? formatAgentModelLabel(input.agent.model)
|
||||
: formatDefaultModelLabel(input.defaultModel);
|
||||
? formatAgentModelLabel(input.agent.model, input.agent.provider)
|
||||
: formatDefaultModelLabel(input.defaultModel, input.agent.provider);
|
||||
}
|
||||
return input.personaModel?.trim()
|
||||
? formatAgentModelLabel(input.personaModel)
|
||||
: formatDefaultModelLabel(input.defaultModel);
|
||||
? formatAgentModelLabel(input.personaModel, input.provider)
|
||||
: formatDefaultModelLabel(input.defaultModel, input.provider);
|
||||
}
|
||||
|
||||
export function formatDefaultModelLabel(defaultModel: string) {
|
||||
export function formatDefaultModelLabel(
|
||||
defaultModel: string,
|
||||
provider?: string | null | undefined,
|
||||
) {
|
||||
const model = defaultModel.trim();
|
||||
return model ? `Default model (${model})` : "Default model";
|
||||
return model
|
||||
? `Default model (${resolveModelLabel(model, undefined, provider)})`
|
||||
: "Default model";
|
||||
}
|
||||
|
||||
@@ -54,12 +54,12 @@ test("persona-catalog: skips fresh focus refetch", async () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("persona-catalog: refetches genuinely stale data on focus", async () => {
|
||||
test("persona-catalog: does not refetch stale data on focus", async () => {
|
||||
assert.equal(
|
||||
await focusRefetchCount({
|
||||
ageMs: personaCatalogFocusRefetchPolicy.staleTime + 1,
|
||||
policy: personaCatalogFocusRefetchPolicy,
|
||||
}),
|
||||
1,
|
||||
0,
|
||||
);
|
||||
});
|
||||
|
||||
@@ -1,8 +1,79 @@
|
||||
import {
|
||||
canonicalizeProvider,
|
||||
databricksRegistryLabel,
|
||||
resolveModelCapabilities,
|
||||
} from "../ui/modelCapabilities";
|
||||
|
||||
// Re-exported so the label surface remains the single import site for provider
|
||||
// canonicalization; the interpreter owns the alias map.
|
||||
export { canonicalizeProvider };
|
||||
|
||||
/**
|
||||
* Resolves a human-readable label for a model, following a three-tier
|
||||
* precedence:
|
||||
*
|
||||
* 1. Non-blank discovered/API name (e.g. from `AgentModelInfo.name`) that is
|
||||
* genuinely distinct from the id. A discovered name that merely echoes the
|
||||
* trimmed id carries no display information, so it is treated as absent and
|
||||
* falls through to the registry tier — this covers buzz-agent's Databricks
|
||||
* discovery contract (`{id, name: id}`) and any harness/version skew that
|
||||
* echoes the id as the name.
|
||||
* 2. Registry lookup by id:
|
||||
* - `provider` supplied → Databricks v2 uses alias-aware exact records;
|
||||
* every other provider uses provider-qualified exact records. On a miss
|
||||
* the raw id is returned; the providerless registry tier is NOT
|
||||
* consulted, so a Databricks endpoint id never leaks a curated label
|
||||
* through an anthropic/openai provider context (the P3-B contract).
|
||||
* - `provider` absent → alias-aware lookup over `databricks_v2` exact
|
||||
* records, for legacy/inherited ids with no provider on hand.
|
||||
* 3. Raw id unchanged.
|
||||
*
|
||||
* Returns the empty string when both id and discoveredName are blank; use
|
||||
* `formatAgentModelLabel` when a null/empty id should render "Auto".
|
||||
*
|
||||
* `resolveModelCapabilities` canonicalizes the provider internally. The
|
||||
* providerless registry lookup applies the same family-token stripping and
|
||||
* unique-match guard as buzz-agent discovery; only unique exact-record aliases
|
||||
* get a label.
|
||||
*/
|
||||
export function resolveModelLabel(
|
||||
id: string,
|
||||
discoveredName?: string | null | undefined,
|
||||
provider?: string | null | undefined,
|
||||
): string {
|
||||
const trimmedName = discoveredName?.trim();
|
||||
const trimmedId = id.trim();
|
||||
// A discovered name distinct from the id is authoritative (tier 1). A name
|
||||
// that merely echoes the id is treated as absent so the registry tier runs.
|
||||
if (trimmedName && trimmedName !== trimmedId) return trimmedName;
|
||||
if (!trimmedId) return "";
|
||||
if (provider?.trim()) {
|
||||
// Provider-qualified exact-record tier (provider-scoped, no providerless fallback).
|
||||
const canonicalProvider = canonicalizeProvider(provider);
|
||||
const registryLabel =
|
||||
canonicalProvider === "databricks_v2"
|
||||
? databricksRegistryLabel(trimmedId)
|
||||
: resolveModelCapabilities(provider, trimmedId).registryLabel;
|
||||
return registryLabel ?? trimmedId;
|
||||
}
|
||||
// Providerless path: alias-aware lookup for legacy/inherited ids.
|
||||
return databricksRegistryLabel(trimmedId) ?? trimmedId;
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns a human-readable model label for an agent or persona, falling back to
|
||||
* "Auto" when no model is set (empty or whitespace-only).
|
||||
*
|
||||
* For known Databricks managed endpoints the registry-curated name is returned
|
||||
* (e.g. "databricks-gpt-5-5" → "GPT-5.5"). Unknown or custom endpoint ids are
|
||||
* returned unchanged — no heuristic string mangling. Pass `provider` when the
|
||||
* inference provider is known to get a provider-qualified registry label.
|
||||
*/
|
||||
export function formatAgentModelLabel(model: string | null | undefined) {
|
||||
export function formatAgentModelLabel(
|
||||
model: string | null | undefined,
|
||||
provider?: string | null | undefined,
|
||||
) {
|
||||
const trimmed = model?.trim();
|
||||
return trimmed && trimmed.length > 0 ? trimmed : "Auto";
|
||||
if (!trimmed) return "Auto";
|
||||
return resolveModelLabel(trimmed, null, provider);
|
||||
}
|
||||
|
||||
@@ -3,10 +3,18 @@ import test from "node:test";
|
||||
|
||||
import { awaitLiveSwitchOutcome } from "./liveSwitchOutcome.ts";
|
||||
|
||||
const MODEL = "goose-claude-fable-5";
|
||||
const REQUEST_ID = "req-abc";
|
||||
const CH_A = "channel-a";
|
||||
const CH_B = "channel-b";
|
||||
|
||||
function frame(status, overrides = {}) {
|
||||
return { type: "switch_model", status, modelId: MODEL, ...overrides };
|
||||
return {
|
||||
type: "switch_model",
|
||||
status,
|
||||
requestId: REQUEST_ID,
|
||||
channelId: CH_A,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -15,7 +23,7 @@ function frame(status, overrides = {}) {
|
||||
* no-ops, matching `observerRelayStore`), a manual timeout, and a deferred
|
||||
* `sendSwitches` the test resolves explicitly.
|
||||
*/
|
||||
function harness(channelCount) {
|
||||
function harness(channelIds, requestId = REQUEST_ID) {
|
||||
let listener = null;
|
||||
let timeoutCb = null;
|
||||
let unsubscribeCalls = 0;
|
||||
@@ -26,8 +34,8 @@ function harness(channelCount) {
|
||||
});
|
||||
|
||||
const outcome = awaitLiveSwitchOutcome({
|
||||
channelCount,
|
||||
modelId: MODEL,
|
||||
requestId,
|
||||
channelIds,
|
||||
subscribe: (fn) => {
|
||||
listener = fn;
|
||||
return () => {
|
||||
@@ -61,47 +69,85 @@ function harness(channelCount) {
|
||||
};
|
||||
}
|
||||
|
||||
const drainMicrotasks = async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
test("awaitLiveSwitchOutcome fast sent on one channel does not mask a later unsupported on another", async () => {
|
||||
const h = harness(2);
|
||||
const h = harness([CH_A, CH_B]);
|
||||
// Channel A acks fast as `sent`; a first-ack-resolves impl would settle "ok"
|
||||
// here. The fail-fast contract must keep waiting and then reject on B.
|
||||
h.push(frame("sent"));
|
||||
h.push(frame("unsupported_model"));
|
||||
h.push(frame("unsupported_model", { channelId: CH_B }));
|
||||
assert.equal(await h.outcome, "unsupported");
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome resolves ok only after the last channel acks", async () => {
|
||||
const h = harness(3);
|
||||
test("awaitLiveSwitchOutcome resolves ok only after every distinct channel acks", async () => {
|
||||
const h = harness([CH_A, CH_B]);
|
||||
let settled = false;
|
||||
void h.outcome.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
// The `.then` that flips `settled` flushes on a later microtask tick than a
|
||||
// single drain, so a single `await Promise.resolve()` would let this
|
||||
// assertion pass even against a first-ack-resolves bug. Draining several
|
||||
// ticks guarantees a resolved promise's callback has run, so the interim
|
||||
// `settled === false` checks deterministically regress an early resolve.
|
||||
const drainMicrotasks = async () => {
|
||||
for (let i = 0; i < 5; i++) {
|
||||
await Promise.resolve();
|
||||
}
|
||||
};
|
||||
|
||||
h.push(frame("sent"));
|
||||
// Terminal success for channel A alone must not settle a two-channel pick.
|
||||
h.push(frame("switched", { channelId: CH_A }));
|
||||
await drainMicrotasks();
|
||||
assert.equal(settled, false, "must not resolve on the first ack");
|
||||
assert.equal(settled, false, "must not resolve before every channel acks");
|
||||
|
||||
h.push(frame("switched", { channelId: CH_B }));
|
||||
assert.equal(await h.outcome, "ok");
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome settles not_delivered immediately on a turn_ending frame without waiting for other channels or the timeout", async () => {
|
||||
// `turn_ending` means the control oneshot was already consumed (a prior
|
||||
// cancel is ending the turn) — the switch can't land and nothing applies
|
||||
// later. It must fail-fast to "not_delivered", NOT count as a positive
|
||||
// terminal. Three channels prove it never traverses the success-count path.
|
||||
const h = harness([CH_A, CH_B, "channel-c"]);
|
||||
h.push(frame("turn_ending"));
|
||||
assert.equal(await h.outcome, "not_delivered");
|
||||
assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited");
|
||||
assert.equal(h.unsubscribeCalls, 1);
|
||||
|
||||
// A later positive frame must not re-resolve or re-unsubscribe.
|
||||
h.push(frame("switched"));
|
||||
assert.equal(h.unsubscribeCalls, 1, "no double-unsubscribe on a late frame");
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome settles not_delivered immediately on a no_active_turn frame", async () => {
|
||||
// `no_active_turn` means neither an in-flight task nor an idle session-owning
|
||||
// agent existed by the time the harness received the switch (a stale
|
||||
// `activeTurns` snapshot). Nothing was applied and nothing rides a later
|
||||
// session — fail-fast to "not_delivered", never a false "ok".
|
||||
const h = harness([CH_A, CH_B]);
|
||||
h.push(frame("no_active_turn"));
|
||||
assert.equal(await h.outcome, "not_delivered");
|
||||
assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited");
|
||||
assert.equal(h.unsubscribeCalls, 1);
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome ignores an unknown future status and settles via a real switched terminal", async () => {
|
||||
// A status the picker doesn't know (a newer harness) must be inert — never
|
||||
// default-counted as success. The pick stays open until a real `switched`
|
||||
// terminal (or the timeout) settles it.
|
||||
const h = harness([CH_A]);
|
||||
let settled = false;
|
||||
void h.outcome.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
h.push(frame("some_future_status"));
|
||||
await drainMicrotasks();
|
||||
assert.equal(settled, false, "an unknown status must not settle the pick");
|
||||
|
||||
h.push(frame("switched"));
|
||||
await drainMicrotasks();
|
||||
assert.equal(settled, false, "must not resolve before the last ack");
|
||||
|
||||
h.push(frame("turn_ending"));
|
||||
assert.equal(await h.outcome, "ok");
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome rejects on unsupported immediately and unsubscribes exactly once", async () => {
|
||||
const h = harness(2);
|
||||
const h = harness([CH_A, CH_B]);
|
||||
h.push(frame("unsupported_model"));
|
||||
assert.equal(await h.outcome, "unsupported");
|
||||
assert.equal(h.unsubscribeCalls, 1);
|
||||
@@ -113,42 +159,309 @@ test("awaitLiveSwitchOutcome rejects on unsupported immediately and unsubscribes
|
||||
assert.equal(h.unsubscribeCalls, 1, "no double-unsubscribe on a late frame");
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome ignores frames for a different model or control type", async () => {
|
||||
const h = harness(1);
|
||||
h.push(frame("sent", { modelId: "some-other-model" }));
|
||||
h.push({ type: "cancel_turn", status: "sent", modelId: MODEL });
|
||||
test("awaitLiveSwitchOutcome settles failed immediately on an adapter-refused frame without waiting for other channels or the timeout", async () => {
|
||||
const h = harness([CH_A, CH_B, "channel-c"]);
|
||||
// Three channels, so a success-path impl would need three acks. A single
|
||||
// `failure` frame must fail-fast to "failed" (not "unsupported", not "ok")
|
||||
// before the other two channels reply — proving it never traverses the
|
||||
// success-count path.
|
||||
h.push(frame("failure"));
|
||||
assert.equal(await h.outcome, "failed");
|
||||
// The timeout was cancelled (no 8s wait) and the listener detached exactly
|
||||
// once — the frame settled synchronously, not via the fallback.
|
||||
assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited");
|
||||
assert.equal(h.unsubscribeCalls, 1);
|
||||
|
||||
// A later frame must not re-resolve or re-unsubscribe.
|
||||
h.push(frame("switched"));
|
||||
assert.equal(h.unsubscribeCalls, 1, "no double-unsubscribe on a late frame");
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome stays unsettled after a provisional sent, then settles failed when the adapter rejection arrives", async () => {
|
||||
// The real busy-path producer order: the harness acks `sent` immediately
|
||||
// (the switch was delivered to the in-flight turn), then — after the requeued
|
||||
// session consults the adapter — emits `failure` seconds later. With one
|
||||
// active channel a first-ack-resolves impl would settle "ok" on `sent` and
|
||||
// detach before `failure` arrives; this regresses that.
|
||||
const h = harness([CH_A]);
|
||||
let settled = false;
|
||||
void h.outcome.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await Promise.resolve();
|
||||
|
||||
h.push(frame("sent"));
|
||||
await drainMicrotasks();
|
||||
assert.equal(settled, false, "provisional `sent` must not resolve the pick");
|
||||
assert.equal(h.unsubscribeCalls, 0, "subscription stays alive after `sent`");
|
||||
assert.equal(h.cancelTimeoutCalls, 0, "timeout still armed after `sent`");
|
||||
|
||||
h.push(frame("failure"));
|
||||
assert.equal(await h.outcome, "failed");
|
||||
assert.equal(h.cancelTimeoutCalls, 1, "timeout cancelled, not awaited");
|
||||
assert.equal(h.unsubscribeCalls, 1);
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome stays unsettled after a provisional sent, then resolves pending via the timeout when no positive terminal arrives", async () => {
|
||||
// Busy-path success now emits a positive `switched` terminal when the
|
||||
// requeued session applies the model — but a busy turn can outlast the
|
||||
// fallback timeout. If no terminal arrives in time, the pick resolves
|
||||
// `"pending"` (accepted, apply deferred), NEVER a false `"ok"`.
|
||||
const h = harness([CH_A]);
|
||||
let settled = false;
|
||||
void h.outcome.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
h.push(frame("sent"));
|
||||
await drainMicrotasks();
|
||||
assert.equal(settled, false, "provisional `sent` must not resolve the pick");
|
||||
assert.equal(h.cancelTimeoutCalls, 0, "timeout still armed after `sent`");
|
||||
|
||||
h.fireTimeout();
|
||||
assert.equal(await h.outcome, "pending");
|
||||
assert.equal(h.unsubscribeCalls, 1, "timeout fallback unsubscribes");
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome resolves ok when the busy-path deferred apply emits a positive switched terminal", async () => {
|
||||
// The K1 mirror case: after the provisional `sent`, the requeued session
|
||||
// applies the model and the harness emits a real `switched` terminal before
|
||||
// the timeout. That positive frame — not timeout silence — resolves `"ok"`.
|
||||
const h = harness([CH_A]);
|
||||
let settled = false;
|
||||
void h.outcome.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
h.push(frame("sent"));
|
||||
await drainMicrotasks();
|
||||
assert.equal(settled, false, "provisional `sent` must not resolve the pick");
|
||||
|
||||
h.push(frame("switched"));
|
||||
assert.equal(await h.outcome, "ok");
|
||||
assert.equal(
|
||||
h.cancelTimeoutCalls,
|
||||
1,
|
||||
"positive terminal cancels the timeout",
|
||||
);
|
||||
assert.equal(h.unsubscribeCalls, 1);
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome never resolves ok when a delayed rejection lands after the timeout already resolved pending", async () => {
|
||||
// The K1 named pin: a busy switch whose turn outlasts the timeout. The pick
|
||||
// resolves `"pending"` at the timeout; the deferred apply then rejects. The
|
||||
// late `failure` frame must not re-resolve, and the outcome is never `"ok"`.
|
||||
const h = harness([CH_A]);
|
||||
|
||||
h.push(frame("sent"));
|
||||
h.fireTimeout();
|
||||
assert.equal(await h.outcome, "pending");
|
||||
assert.equal(h.unsubscribeCalls, 1, "timeout fallback detaches the listener");
|
||||
|
||||
// Deferred apply rejects after the fact: inert, the listener is gone.
|
||||
h.push(frame("failure"));
|
||||
assert.equal(
|
||||
h.unsubscribeCalls,
|
||||
1,
|
||||
"no re-resolve or re-unsubscribe on a late frame",
|
||||
);
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome ignores frames for a different request id or control type", async () => {
|
||||
const h = harness([CH_A]);
|
||||
// A replayed terminal frame from an EARLIER pick carries a different
|
||||
// requestId; it must not advance this pick's count.
|
||||
h.push(frame("switched", { requestId: "req-stale" }));
|
||||
h.push({
|
||||
type: "cancel_turn",
|
||||
status: "sent",
|
||||
requestId: REQUEST_ID,
|
||||
channelId: CH_A,
|
||||
});
|
||||
let settled = false;
|
||||
void h.outcome.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
await drainMicrotasks();
|
||||
assert.equal(settled, false, "unrelated frames must not advance the count");
|
||||
|
||||
h.push(frame("switched"));
|
||||
assert.equal(await h.outcome, "ok");
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome resolves ok via the timeout fallback when the harness never replies", async () => {
|
||||
const h = harness(2);
|
||||
test("awaitLiveSwitchOutcome resolves pending via the timeout fallback when the harness never replies", async () => {
|
||||
const h = harness([CH_A, CH_B]);
|
||||
h.fireTimeout();
|
||||
assert.equal(await h.outcome, "ok");
|
||||
assert.equal(await h.outcome, "pending");
|
||||
assert.equal(h.unsubscribeCalls, 1, "timeout fallback unsubscribes");
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome fires the per-channel sends after subscribing", async () => {
|
||||
const h = harness(1);
|
||||
const h = harness([CH_A]);
|
||||
// The subscription is registered before the sends fire, so a frame arriving
|
||||
// mid-send is never dropped. Awaiting sendStarted proves sends ran.
|
||||
// mid-send is never dropped. Awaiting sendStarted proves sends ran. Uses a
|
||||
// terminal-success frame (`switched`) since the provisional `sent` no longer
|
||||
// settles the pick on its own.
|
||||
await h.sendStarted;
|
||||
h.push(frame("sent"));
|
||||
h.push(frame("switched"));
|
||||
assert.equal(await h.outcome, "ok");
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome with zero channels resolves ok at the timeout (no acks expected)", async () => {
|
||||
// No active turns means channelCount 0: remaining starts at 0 but the success
|
||||
// resolve only fires inside a frame callback, so with no frames the timeout
|
||||
// fallback is what settles it. This documents the degenerate path.
|
||||
const h = harness(0);
|
||||
test("awaitLiveSwitchOutcome with zero channels resolves pending at the timeout (no acks expected)", async () => {
|
||||
// No active turns means an empty channel set: the success resolve only fires
|
||||
// inside a frame callback keyed on an expected channel, so with no frames the
|
||||
// timeout fallback is what settles it — to `"pending"`, since no positive
|
||||
// terminal confirmed. This documents the degenerate path.
|
||||
const h = harness([]);
|
||||
h.fireTimeout();
|
||||
assert.equal(await h.outcome, "pending");
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome ignores a reconnect replay of an identical terminal frame", async () => {
|
||||
// The observer relay requests a five-minute replay on reconnect, so the SAME
|
||||
// terminal frame for one channel can arrive twice. A scalar count would treat
|
||||
// the replay as a second channel's ack and settle a two-channel pick early.
|
||||
const h = harness([CH_A, CH_B]);
|
||||
let settled = false;
|
||||
void h.outcome.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
h.push(frame("switched", { channelId: CH_A }));
|
||||
h.push(frame("switched", { channelId: CH_A })); // replay of the same frame
|
||||
await drainMicrotasks();
|
||||
assert.equal(
|
||||
settled,
|
||||
false,
|
||||
"a duplicated channel-A success must not stand in for channel B",
|
||||
);
|
||||
|
||||
h.push(frame("switched", { channelId: CH_B }));
|
||||
assert.equal(await h.outcome, "ok");
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome does not let one channel's duplicated success mask another channel's later failure", async () => {
|
||||
// Two channels: A succeeds and its frame is replayed; B rejects late. A
|
||||
// per-frame count would resolve "ok" on A's duplicate before B's failure and
|
||||
// report a false success. Counting per distinct channel keeps the pick open
|
||||
// for B, which fail-fasts to "failed".
|
||||
const h = harness([CH_A, CH_B]);
|
||||
let settled = false;
|
||||
void h.outcome.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
h.push(frame("switched", { channelId: CH_A }));
|
||||
h.push(frame("switched", { channelId: CH_A })); // duplicate for A
|
||||
await drainMicrotasks();
|
||||
assert.equal(settled, false, "A's duplicate must not complete the pick");
|
||||
|
||||
h.push(frame("failure", { channelId: CH_B }));
|
||||
assert.equal(await h.outcome, "failed");
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome ignores a stale result from an overlapping same-model operation", async () => {
|
||||
// Two picks for the same model overlap: this operation is `req-new`; a prior
|
||||
// `req-old` pick's terminal frame (same model, same channel) is still in
|
||||
// flight. Correlating on requestId — not modelId — keeps the old result from
|
||||
// settling the new pick.
|
||||
const h = harness([CH_A], "req-new");
|
||||
let settled = false;
|
||||
void h.outcome.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
h.push({
|
||||
type: "switch_model",
|
||||
status: "switched",
|
||||
requestId: "req-old",
|
||||
channelId: CH_A,
|
||||
});
|
||||
await drainMicrotasks();
|
||||
assert.equal(settled, false, "a prior same-model pick's result is inert");
|
||||
|
||||
h.push({
|
||||
type: "switch_model",
|
||||
status: "switched",
|
||||
requestId: "req-new",
|
||||
channelId: CH_A,
|
||||
});
|
||||
assert.equal(await h.outcome, "ok");
|
||||
});
|
||||
|
||||
// The channel guard must run BEFORE status handling so a negative frame is
|
||||
// correlated by channel too — a misrouted or channel-less `failure`/
|
||||
// `unsupported_model` carrying this pick's requestId must not fail it. This is
|
||||
// the false-failure mirror of the false-success class the positive-terminal
|
||||
// channel count already guards.
|
||||
test("awaitLiveSwitchOutcome ignores a failure frame from a foreign channel", async () => {
|
||||
const h = harness([CH_A]);
|
||||
let settled = false;
|
||||
void h.outcome.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
// Same requestId, but a channel this pick never fired to: inert.
|
||||
h.push(frame("failure", { channelId: "channel-foreign" }));
|
||||
await drainMicrotasks();
|
||||
assert.equal(
|
||||
settled,
|
||||
false,
|
||||
"a foreign-channel failure must not fail the pick",
|
||||
);
|
||||
|
||||
h.push(frame("switched", { channelId: CH_A }));
|
||||
assert.equal(await h.outcome, "ok");
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome ignores an unsupported_model frame from a foreign channel", async () => {
|
||||
const h = harness([CH_A]);
|
||||
let settled = false;
|
||||
void h.outcome.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
h.push(frame("unsupported_model", { channelId: "channel-foreign" }));
|
||||
await drainMicrotasks();
|
||||
assert.equal(
|
||||
settled,
|
||||
false,
|
||||
"a foreign-channel unsupported_model must not fail the pick",
|
||||
);
|
||||
|
||||
h.push(frame("switched", { channelId: CH_A }));
|
||||
assert.equal(await h.outcome, "ok");
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome ignores a failure frame that carries no channel", async () => {
|
||||
const h = harness([CH_A]);
|
||||
let settled = false;
|
||||
void h.outcome.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
h.push(frame("failure", { channelId: undefined }));
|
||||
await drainMicrotasks();
|
||||
assert.equal(settled, false, "a channel-less failure must not fail the pick");
|
||||
|
||||
h.push(frame("switched", { channelId: CH_A }));
|
||||
assert.equal(await h.outcome, "ok");
|
||||
});
|
||||
|
||||
test("awaitLiveSwitchOutcome ignores an unsupported_model frame that carries no channel", async () => {
|
||||
const h = harness([CH_A]);
|
||||
let settled = false;
|
||||
void h.outcome.then(() => {
|
||||
settled = true;
|
||||
});
|
||||
|
||||
h.push(frame("unsupported_model", { channelId: undefined }));
|
||||
await drainMicrotasks();
|
||||
assert.equal(
|
||||
settled,
|
||||
false,
|
||||
"a channel-less unsupported_model must not fail the pick",
|
||||
);
|
||||
|
||||
h.push(frame("switched", { channelId: CH_A }));
|
||||
assert.equal(await h.outcome, "ok");
|
||||
});
|
||||
|
||||
@@ -4,57 +4,146 @@ import type { ControlResultFrame } from "@/shared/api/types";
|
||||
* Resolve the outcome of a live `switch_model` across one or more channels.
|
||||
*
|
||||
* A live switch fires a `switch_model` frame per active channel and learns each
|
||||
* channel's result asynchronously over the observer relay. The fail-fast rule:
|
||||
* any single `unsupported_model` result rejects the whole pick immediately;
|
||||
* every other status must arrive from every channel before resolving success.
|
||||
* If the harness never replies, the fallback timeout resolves `"ok"` — the
|
||||
* override still rides the requeued/next session, we just can't confirm it
|
||||
* synchronously.
|
||||
* channel's result asynchronously over the observer relay. Two statuses
|
||||
* fail-fast — any single frame rejects the whole pick immediately, without
|
||||
* waiting for the other channels or the timeout:
|
||||
* - `unsupported_model` → the target model isn't available for this agent.
|
||||
* - `failure` → the adapter refused the switch (the session stays
|
||||
* on its current model).
|
||||
* Their causes differ, so they resolve to distinct outcomes (`"unsupported"`
|
||||
* vs `"failed"`) the caller can message separately.
|
||||
*
|
||||
* `sent` is the busy-path PROVISIONAL ack: the switch was delivered to the
|
||||
* in-flight turn, but the adapter isn't consulted until the requeued session
|
||||
* runs. The real verdict lands later as a positive `switched` terminal (the
|
||||
* deferred apply succeeded), a `failure`/`unsupported_model` frame (it didn't),
|
||||
* so `sent` never settles the pick on its own — the subscription stays alive
|
||||
* for the terminal frame.
|
||||
*
|
||||
* Success is only ever inferred from `switched` — the one status that means
|
||||
* the model was APPLIED — which must arrive from every EXPECTED channel before
|
||||
* resolving `"ok"`. The idle path emits it immediately; the busy path emits it
|
||||
* when the requeued session applies the model. A busy turn routinely outlasts
|
||||
* the fallback timeout, so the timeout NEVER resolves `"ok"` — it resolves
|
||||
* `"pending"`: the switch was accepted and rides the requeued/next session, but
|
||||
* we could not confirm the apply synchronously. The caller surfaces that
|
||||
* truthfully rather than claiming a success that has not happened (and might
|
||||
* yet be rejected).
|
||||
*
|
||||
* Two more statuses are non-delivery terminals — the harness never set the
|
||||
* desired model and nothing applies later, so they can no more resolve `"ok"`
|
||||
* than a `failure` can:
|
||||
* - `turn_ending` → the control oneshot was already consumed (a prior
|
||||
* cancel is ending the turn), so the switch can't land.
|
||||
* - `no_active_turn` → neither an in-flight task nor an idle session-owning
|
||||
* agent existed (a stale `activeTurns` snapshot between
|
||||
* the picker read and the harness receipt).
|
||||
* Both fail-fast to `"not_delivered"`, distinct from `"pending"` (which DID
|
||||
* ride the requeued session): here the switch never landed at all.
|
||||
*
|
||||
* Any other status — a `sent` provisional ack, or an unknown future status — is
|
||||
* inert: it is never counted as success. A new producer status that should
|
||||
* settle the pick must add its own explicit branch.
|
||||
*
|
||||
* Two identity guards keep a stale or replayed frame from settling the wrong
|
||||
* pick. The observer relay requests a five-minute replay on reconnect, so an
|
||||
* old `control_result` for an earlier switch can re-arrive mid-pick:
|
||||
* - `requestId` — an opaque per-pick correlator the harness echoes on every
|
||||
* frame. Frames without a matching id are ignored, so a replayed result for
|
||||
* a prior operation (which carried a different id, or none) is inert.
|
||||
* - `channelId` — every frame, positive OR negative, must name a channel in
|
||||
* the EXPECTED set before it can settle anything. A misrouted `failure`
|
||||
* from a foreign channel (or a frame with no channel) can no more fail the
|
||||
* pick than a foreign `switched` can satisfy it. Positive terminals are
|
||||
* then counted once per DISTINCT expected channel, not once per frame, so
|
||||
* two copies of one channel's `switched` can't satisfy a two-channel pick.
|
||||
*
|
||||
* The counting lives here, isolated from React and the relay so it can be unit
|
||||
* tested with synthetic frames and a fake clock. The caller injects the
|
||||
* relay subscription, the per-channel sends, and the timeout scheduler.
|
||||
*/
|
||||
export async function awaitLiveSwitchOutcome({
|
||||
channelCount,
|
||||
modelId,
|
||||
requestId,
|
||||
channelIds,
|
||||
subscribe,
|
||||
sendSwitches,
|
||||
scheduleTimeout,
|
||||
}: {
|
||||
/** Number of channels the switch was fired to — the success threshold. */
|
||||
channelCount: number;
|
||||
/** Model being switched to; frames for any other model are ignored. */
|
||||
modelId: string;
|
||||
/** Opaque per-pick id; frames without this exact id are ignored. */
|
||||
requestId: string;
|
||||
/** Channels the switch was fired to — the distinct set to await. */
|
||||
channelIds: readonly string[];
|
||||
/** Register a control-result listener; returns an unsubscribe function. */
|
||||
subscribe: (listener: (frame: ControlResultFrame) => void) => () => void;
|
||||
/** Fire the per-channel `switch_model` sends. Resolves when all are sent. */
|
||||
sendSwitches: () => Promise<void>;
|
||||
/** Schedule the no-reply fallback; returns a cancel function. */
|
||||
scheduleTimeout: (onTimeout: () => void) => () => void;
|
||||
}): Promise<"ok" | "unsupported"> {
|
||||
const settled = new Promise<"ok" | "unsupported">((resolve) => {
|
||||
}): Promise<"ok" | "unsupported" | "failed" | "not_delivered" | "pending"> {
|
||||
const expected = new Set(channelIds);
|
||||
const settled = new Promise<
|
||||
"ok" | "unsupported" | "failed" | "not_delivered" | "pending"
|
||||
>((resolve) => {
|
||||
let unsubscribe = () => {};
|
||||
let cancelTimeout = () => {};
|
||||
let remaining = channelCount;
|
||||
const finish = (outcome: "ok" | "unsupported") => {
|
||||
const succeeded = new Set<string>();
|
||||
const finish = (
|
||||
outcome: "ok" | "unsupported" | "failed" | "not_delivered" | "pending",
|
||||
) => {
|
||||
cancelTimeout();
|
||||
unsubscribe();
|
||||
resolve(outcome);
|
||||
};
|
||||
cancelTimeout = scheduleTimeout(() => finish("ok"));
|
||||
// No positive terminal in time: the switch was accepted but its deferred
|
||||
// apply hasn't confirmed. Resolve indeterminate — never a false "ok".
|
||||
cancelTimeout = scheduleTimeout(() => finish("pending"));
|
||||
unsubscribe = subscribe((frame) => {
|
||||
if (frame.type !== "switch_model" || frame.modelId !== modelId) {
|
||||
// Two identity guards run BEFORE any status handling, so they scope
|
||||
// every decision — positive AND negative — to THIS pick's channels:
|
||||
// - requestId: a replayed result for a prior operation carries a
|
||||
// different id (or none) and is ignored.
|
||||
// - channelId: the frame must name an EXPECTED channel. A negative
|
||||
// frame (`failure`/`unsupported_model`) misrouted from a foreign
|
||||
// channel, or carrying no channel at all, must not fail this pick
|
||||
// any more than a foreign positive frame may satisfy it.
|
||||
if (frame.type !== "switch_model" || frame.requestId !== requestId) {
|
||||
return;
|
||||
}
|
||||
if (!frame.channelId || !expected.has(frame.channelId)) {
|
||||
return;
|
||||
}
|
||||
if (frame.status === "unsupported_model") {
|
||||
// Any single failure rejects the whole pick immediately.
|
||||
// Model unavailable — reject the whole pick immediately.
|
||||
finish("unsupported");
|
||||
return;
|
||||
}
|
||||
// sent / switched / turn_ending — count as success for this channel.
|
||||
remaining -= 1;
|
||||
if (remaining <= 0) {
|
||||
if (frame.status === "failure") {
|
||||
// Adapter refused the switch — reject immediately. The session stays
|
||||
// on its current model; distinct outcome so the caller can say why.
|
||||
finish("failed");
|
||||
return;
|
||||
}
|
||||
if (frame.status === "turn_ending" || frame.status === "no_active_turn") {
|
||||
// Non-delivery terminal: the harness never set the desired model and
|
||||
// nothing applies later (`turn_ending` = the control oneshot was
|
||||
// already consumed; `no_active_turn` = no in-flight task and no idle
|
||||
// session-owning agent). Fail-fast, distinct from `"pending"` — here
|
||||
// the switch never landed at all.
|
||||
finish("not_delivered");
|
||||
return;
|
||||
}
|
||||
if (frame.status !== "switched") {
|
||||
// Anything else — the provisional `sent` ack, or an unknown future
|
||||
// status — is inert. Only `switched` (the model was APPLIED) counts.
|
||||
// A busy `sent` is settled later by its own `switched`/`failure`
|
||||
// terminal, or by the timeout resolving `"pending"`.
|
||||
return;
|
||||
}
|
||||
// `switched` — the model was applied for this channel. Count each
|
||||
// expected channel once: a duplicate frame for a channel already
|
||||
// recorded (a replay, or a two-copy fan-out) is a no-op.
|
||||
succeeded.add(frame.channelId);
|
||||
if (succeeded.size >= expected.size) {
|
||||
finish("ok");
|
||||
}
|
||||
});
|
||||
|
||||
35
desktop/src/features/agents/lib/otherSetupAgent.test.mjs
Normal file
35
desktop/src/features/agents/lib/otherSetupAgent.test.mjs
Normal file
@@ -0,0 +1,35 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { isOtherSetupAgent } from "./otherSetupAgent.ts";
|
||||
|
||||
const OWNER = "a".repeat(64);
|
||||
const AGENT = "b".repeat(64);
|
||||
|
||||
test("fails closed while the local managed directory is unresolved", () => {
|
||||
assert.equal(
|
||||
isOtherSetupAgent({
|
||||
agentDirectoriesReady: false,
|
||||
currentPubkey: OWNER,
|
||||
managedAgents: [],
|
||||
profileOwnerPubkey: OWNER,
|
||||
pubkey: AGENT,
|
||||
relayAgents: [],
|
||||
}),
|
||||
false,
|
||||
);
|
||||
});
|
||||
|
||||
test("labels a viewer-owned non-local identity as another setup", () => {
|
||||
assert.equal(
|
||||
isOtherSetupAgent({
|
||||
agentDirectoriesReady: true,
|
||||
currentPubkey: OWNER,
|
||||
managedAgents: [],
|
||||
profileOwnerPubkey: OWNER,
|
||||
pubkey: AGENT,
|
||||
relayAgents: [],
|
||||
}),
|
||||
true,
|
||||
);
|
||||
});
|
||||
39
desktop/src/features/agents/lib/otherSetupAgent.ts
Normal file
39
desktop/src/features/agents/lib/otherSetupAgent.ts
Normal file
@@ -0,0 +1,39 @@
|
||||
import type { ManagedAgent, RelayAgent } from "@/shared/api/types";
|
||||
import { normalizePubkey } from "@/shared/lib/pubkey";
|
||||
|
||||
export function isOtherSetupAgent({
|
||||
agentDirectoriesReady,
|
||||
currentPubkey,
|
||||
managedAgents,
|
||||
profileOwnerPubkey,
|
||||
pubkey,
|
||||
relayAgents,
|
||||
}: {
|
||||
agentDirectoriesReady: boolean;
|
||||
currentPubkey?: string;
|
||||
managedAgents: readonly ManagedAgent[];
|
||||
profileOwnerPubkey?: string | null;
|
||||
pubkey: string;
|
||||
relayAgents: readonly RelayAgent[];
|
||||
}): boolean {
|
||||
if (!agentDirectoriesReady || !currentPubkey) return false;
|
||||
|
||||
const normalizedPubkey = normalizePubkey(pubkey);
|
||||
if (
|
||||
managedAgents.some(
|
||||
(agent) => normalizePubkey(agent.pubkey) === normalizedPubkey,
|
||||
)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const relayOwnerPubkey = relayAgents.find(
|
||||
(agent) => normalizePubkey(agent.pubkey) === normalizedPubkey,
|
||||
)?.ownerPubkey;
|
||||
const ownerPubkey = profileOwnerPubkey ?? relayOwnerPubkey;
|
||||
|
||||
return Boolean(
|
||||
ownerPubkey &&
|
||||
normalizePubkey(ownerPubkey) === normalizePubkey(currentPubkey),
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,26 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { mock } from "node:test";
|
||||
|
||||
import { fetchPersonaCatalogPublications } from "./personaCatalogRelay.ts";
|
||||
|
||||
function installTauriInvoke(handler) {
|
||||
globalThis.window ??= {};
|
||||
window.__TAURI_INTERNALS__ = { invoke: handler };
|
||||
}
|
||||
|
||||
test("persona catalog wrapper invokes the one native active-scope command", async (t) => {
|
||||
const prior = globalThis.window;
|
||||
t.after(() => {
|
||||
mock.restoreAll();
|
||||
globalThis.window = prior;
|
||||
});
|
||||
const expected = [{ eventId: "event-1", ownerPubkey: "alice" }];
|
||||
const calls = [];
|
||||
installTauriInvoke((command, args) => {
|
||||
calls.push([command, args]);
|
||||
return Promise.resolve(expected);
|
||||
});
|
||||
|
||||
assert.deepEqual(await fetchPersonaCatalogPublications(), expected);
|
||||
assert.deepEqual(calls, [["fetch_persona_catalog", {}]]);
|
||||
});
|
||||
@@ -1,270 +1,32 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test, { mock } from "node:test";
|
||||
import test from "node:test";
|
||||
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import { emojiAvatarDataUrl } from "@/features/profile/ui/ProfileAvatarEditor.utils.ts";
|
||||
import {
|
||||
catalogPersonasFromPublications,
|
||||
catalogPublicationsFromEvents,
|
||||
fetchPersonaCatalogPublications,
|
||||
personaEventIsShared,
|
||||
} from "./personaCatalogRelay.ts";
|
||||
import { catalogPersonasFromPublications } from "./personaCatalogRelay.ts";
|
||||
|
||||
const ALICE = "a".repeat(64);
|
||||
const BOB = "b".repeat(64);
|
||||
|
||||
function personaEvent({
|
||||
createdAt,
|
||||
id,
|
||||
owner = ALICE,
|
||||
sourcePersonaId = "reviewer",
|
||||
shared = true,
|
||||
avatarUrl = null,
|
||||
respondTo = null,
|
||||
sharedTag,
|
||||
}) {
|
||||
function publication(overrides = {}) {
|
||||
return {
|
||||
id,
|
||||
pubkey: owner,
|
||||
created_at: createdAt,
|
||||
kind: 30175,
|
||||
tags: [
|
||||
["d", sourcePersonaId],
|
||||
...(shared
|
||||
? [sharedTag ?? ["shared", "true"]]
|
||||
: sharedTag
|
||||
? [sharedTag]
|
||||
: []),
|
||||
],
|
||||
content: JSON.stringify({
|
||||
display_name: "Relay Reviewer",
|
||||
system_prompt: "Review changes.",
|
||||
avatar_url: avatarUrl,
|
||||
runtime: "goose",
|
||||
model: "claude",
|
||||
eventId: "event-1",
|
||||
ownerPubkey: ALICE,
|
||||
sourcePersonaId: "reviewer",
|
||||
createdAt: 1,
|
||||
agent: {
|
||||
displayName: "Relay Reviewer",
|
||||
avatarUrl: null,
|
||||
systemPrompt: "Review changes.",
|
||||
runtime: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
name_pool: ["Reviewer"],
|
||||
respond_to: respondTo,
|
||||
respond_to_allowlist: respondTo === "allowlist" ? [BOB] : undefined,
|
||||
parallelism: 4,
|
||||
}),
|
||||
sig: "sig",
|
||||
namePool: [],
|
||||
respondTo: null,
|
||||
parallelism: null,
|
||||
},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("a shared kind 30175 persona from Alice is discoverable by Bob", () => {
|
||||
const publications = catalogPublicationsFromEvents([
|
||||
personaEvent({ createdAt: 1, id: "alice-reviewer" }),
|
||||
]);
|
||||
const personas = catalogPersonasFromPublications(publications, [], BOB);
|
||||
|
||||
assert.equal(personas.length, 1);
|
||||
assert.equal(personas[0].displayName, "Relay Reviewer");
|
||||
assert.equal(personas[0].isActive, false);
|
||||
assert.equal(personas[0].shared, true);
|
||||
assert.equal(personas[0].catalogSource.ownerPubkey, ALICE);
|
||||
assert.equal(personas[0].catalogSource.isOwn, false);
|
||||
});
|
||||
|
||||
test("a newer unshared head hides the older shared head", () => {
|
||||
const publications = catalogPublicationsFromEvents([
|
||||
personaEvent({ createdAt: 1, id: "shared" }),
|
||||
personaEvent({ createdAt: 2, id: "unshared", shared: false }),
|
||||
]);
|
||||
|
||||
assert.deepEqual(publications, []);
|
||||
});
|
||||
|
||||
test("persona coordinates remain independent across authors", () => {
|
||||
const publications = catalogPublicationsFromEvents([
|
||||
personaEvent({ createdAt: 1, id: "alice", owner: ALICE }),
|
||||
personaEvent({ createdAt: 1, id: "bob", owner: BOB }),
|
||||
]);
|
||||
|
||||
assert.equal(publications.length, 2);
|
||||
assert.equal(
|
||||
catalogPersonasFromPublications(publications, [], BOB).length,
|
||||
2,
|
||||
);
|
||||
});
|
||||
|
||||
test("equal-second persona heads use the relay lowest-id tie-break", () => {
|
||||
const publications = catalogPublicationsFromEvents([
|
||||
personaEvent({
|
||||
createdAt: 1,
|
||||
id: "b".repeat(64),
|
||||
shared: true,
|
||||
}),
|
||||
personaEvent({
|
||||
createdAt: 1,
|
||||
id: "a".repeat(64),
|
||||
shared: false,
|
||||
}),
|
||||
]);
|
||||
|
||||
assert.deepEqual(publications, []);
|
||||
});
|
||||
|
||||
test("an invalid canonical head does not resurrect an older shared persona", () => {
|
||||
const invalidHead = {
|
||||
...personaEvent({ createdAt: 2, id: "a".repeat(64) }),
|
||||
content: "{}",
|
||||
};
|
||||
const publications = catalogPublicationsFromEvents([
|
||||
personaEvent({ createdAt: 1, id: "older-valid" }),
|
||||
invalidHead,
|
||||
]);
|
||||
|
||||
assert.deepEqual(publications, []);
|
||||
});
|
||||
|
||||
test("only an exact shared true tag opts a persona into discovery", () => {
|
||||
assert.equal(
|
||||
personaEventIsShared(personaEvent({ createdAt: 1, id: "exact-shared" })),
|
||||
true,
|
||||
);
|
||||
for (const [index, sharedTag] of [
|
||||
["shared"],
|
||||
["shared", "false"],
|
||||
["shared", "true", "extra"],
|
||||
].entries()) {
|
||||
const event = personaEvent({
|
||||
createdAt: index + 2,
|
||||
id: `malformed-${index}`,
|
||||
shared: false,
|
||||
sharedTag,
|
||||
});
|
||||
assert.equal(personaEventIsShared(event), false);
|
||||
assert.deepEqual(catalogPublicationsFromEvents([event]), []);
|
||||
}
|
||||
const duplicate = personaEvent({
|
||||
createdAt: 5,
|
||||
id: "duplicate",
|
||||
});
|
||||
duplicate.tags.push(["shared", "true"]);
|
||||
assert.equal(personaEventIsShared(duplicate), false);
|
||||
});
|
||||
|
||||
test("catalog avatars keep bounded http URLs and drop unsafe schemes", () => {
|
||||
const safe = catalogPersonasFromPublications(
|
||||
catalogPublicationsFromEvents([
|
||||
personaEvent({
|
||||
createdAt: 1,
|
||||
id: "safe-avatar",
|
||||
avatarUrl: "https://relay.example/avatar.png",
|
||||
}),
|
||||
]),
|
||||
[],
|
||||
BOB,
|
||||
);
|
||||
assert.equal(safe[0].avatarUrl, "https://relay.example/avatar.png");
|
||||
|
||||
const unsafe = catalogPersonasFromPublications(
|
||||
catalogPublicationsFromEvents([
|
||||
personaEvent({
|
||||
createdAt: 1,
|
||||
id: "unsafe-avatar",
|
||||
avatarUrl: "javascript:alert(1)",
|
||||
}),
|
||||
]),
|
||||
[],
|
||||
BOB,
|
||||
);
|
||||
assert.equal(unsafe[0].avatarUrl, null);
|
||||
});
|
||||
|
||||
/** The avatar a catalog entry projects for `avatarUrl`, or null if dropped. */
|
||||
function catalogAvatarUrl(avatarUrl) {
|
||||
const personas = catalogPersonasFromPublications(
|
||||
catalogPublicationsFromEvents([
|
||||
personaEvent({ createdAt: 1, id: "avatar-vector", avatarUrl }),
|
||||
]),
|
||||
[],
|
||||
BOB,
|
||||
);
|
||||
return personas[0].avatarUrl;
|
||||
}
|
||||
|
||||
// An emoji avatar is self-contained, so it is the one `data:` avatar that can
|
||||
// render on another member's machine. Dropping it left shared agents looking
|
||||
// avatar-less in the catalog.
|
||||
test("test_percent_encoded_emoji_svg_avatar_survives_the_catalog", () => {
|
||||
const emojiAvatar = emojiAvatarDataUrl("🐝", "#FFCC00");
|
||||
|
||||
assert.equal(catalogAvatarUrl(emojiAvatar), emojiAvatar);
|
||||
});
|
||||
|
||||
test("test_base64_svg_avatar_is_rejected", () => {
|
||||
assert.equal(
|
||||
catalogAvatarUrl(`data:image/svg+xml;base64,${btoa("<svg/>")}`),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("test_non_svg_data_avatar_is_rejected", () => {
|
||||
assert.equal(catalogAvatarUrl("data:image/png,%89PNG"), null);
|
||||
});
|
||||
|
||||
test("test_legacy_inline_raster_avatar_survives_the_catalog", () => {
|
||||
for (const mime of ["png", "jpeg", "gif", "webp"]) {
|
||||
const avatar = `data:image/${mime};base64,iVBORw0KGgo=`;
|
||||
assert.equal(catalogAvatarUrl(avatar), avatar);
|
||||
}
|
||||
});
|
||||
|
||||
test("test_inline_raster_avatar_rejects_unbounded_or_malformed_payloads", () => {
|
||||
const prefix = "data:image/png;base64,";
|
||||
const payloadLength = 256 * 1_024 - prefix.length;
|
||||
const validPayloadLength = payloadLength - (payloadLength % 4);
|
||||
const withinCap = `${prefix}${"a".repeat(validPayloadLength - 2)}==`;
|
||||
assert.ok(withinCap.length <= 256 * 1_024);
|
||||
assert.equal(catalogAvatarUrl(withinCap), withinCap);
|
||||
assert.equal(
|
||||
catalogAvatarUrl(
|
||||
`${withinCap}${"a".repeat(256 * 1_024 - withinCap.length + 1)}`,
|
||||
),
|
||||
null,
|
||||
);
|
||||
assert.equal(catalogAvatarUrl("data:image/png;base64,not base64"), null);
|
||||
assert.equal(catalogAvatarUrl("data:image/bmp;base64,aA=="), null);
|
||||
});
|
||||
|
||||
test("test_oversized_inline_svg_avatar_is_rejected", () => {
|
||||
const withinCap = `data:image/svg+xml,${"a".repeat(8_192 - "data:image/svg+xml,".length)}`;
|
||||
assert.equal(withinCap.length, 8_192);
|
||||
assert.equal(catalogAvatarUrl(withinCap), withinCap);
|
||||
assert.equal(catalogAvatarUrl(`${withinCap}a`), null);
|
||||
});
|
||||
|
||||
// Catalog avatars render through `<img src>` (ProfileAvatar → AvatarImage),
|
||||
// where an SVG document is never scripted, so a script-bearing avatar is
|
||||
// accepted and inert rather than filtered — the projection must not silently
|
||||
// start sanitizing markup it does not render.
|
||||
test("test_script_bearing_inline_svg_avatar_is_accepted_and_rendered_inert", () => {
|
||||
const scripted = `data:image/svg+xml,${encodeURIComponent(
|
||||
'<svg xmlns="http://www.w3.org/2000/svg"><script>alert(1)</script></svg>',
|
||||
)}`;
|
||||
|
||||
assert.equal(catalogAvatarUrl(scripted), scripted);
|
||||
});
|
||||
|
||||
test("foreign allowlist behavior imports as owner-only", () => {
|
||||
const personas = catalogPersonasFromPublications(
|
||||
catalogPublicationsFromEvents([
|
||||
personaEvent({
|
||||
createdAt: 1,
|
||||
id: "allowlist",
|
||||
respondTo: "allowlist",
|
||||
}),
|
||||
]),
|
||||
[],
|
||||
BOB,
|
||||
);
|
||||
|
||||
assert.equal(personas[0].respondTo, "owner-only");
|
||||
assert.deepEqual(personas[0].respondToAllowlist, []);
|
||||
});
|
||||
|
||||
test("a pending local share does not appear before relay confirmation", () => {
|
||||
const localPersona = {
|
||||
id: "local-reviewer",
|
||||
@@ -321,13 +83,11 @@ function localPersona(overrides = {}) {
|
||||
// stored catalogSource coordinate links the copy back to the publication.
|
||||
test("test_added_foreign_catalog_entry_keeps_publisher_identity_and_local_selection", () => {
|
||||
const publisherAvatar = "https://relay.example/publisher.png";
|
||||
const publications = catalogPublicationsFromEvents([
|
||||
personaEvent({
|
||||
createdAt: 1,
|
||||
id: "alice-reviewer",
|
||||
avatarUrl: publisherAvatar,
|
||||
const publications = [
|
||||
publication({
|
||||
agent: { ...publication().agent, avatarUrl: publisherAvatar },
|
||||
}),
|
||||
]);
|
||||
];
|
||||
const copy = localPersona({
|
||||
id: "a-fresh-uuid",
|
||||
displayName: "Locally Renamed Reviewer",
|
||||
@@ -355,9 +115,7 @@ test("test_added_foreign_catalog_entry_keeps_publisher_identity_and_local_select
|
||||
});
|
||||
|
||||
test("test_foreign_entry_with_no_local_copy_stays_unselected", () => {
|
||||
const publications = catalogPublicationsFromEvents([
|
||||
personaEvent({ createdAt: 1, id: "alice-reviewer" }),
|
||||
]);
|
||||
const publications = [publication()];
|
||||
// A same-named local persona with no provenance is a different agent.
|
||||
const unrelated = localPersona({ id: "unrelated" });
|
||||
|
||||
@@ -367,16 +125,14 @@ test("test_foreign_entry_with_no_local_copy_stays_unselected", () => {
|
||||
BOB,
|
||||
);
|
||||
|
||||
assert.equal(personas[0].id, "catalog:" + ALICE + ":reviewer");
|
||||
assert.equal(personas[0].id, `catalog:${ALICE}:reviewer`);
|
||||
assert.equal(personas[0].isActive, false);
|
||||
});
|
||||
|
||||
// Provenance is per-owner: the same d-tag under a different publisher is a
|
||||
// different agent, so a copy of Alice's must not mask Bob's entry.
|
||||
test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => {
|
||||
const publications = catalogPublicationsFromEvents([
|
||||
personaEvent({ createdAt: 1, id: "bob-reviewer", owner: BOB }),
|
||||
]);
|
||||
const publications = [publication({ ownerPubkey: BOB })];
|
||||
const copyOfAlices = localPersona({
|
||||
id: "copy-of-alices",
|
||||
catalogSource: { ownerPubkey: ALICE, personaId: "reviewer" },
|
||||
@@ -388,14 +144,12 @@ test("test_catalog_source_match_is_scoped_to_the_publishing_owner", () => {
|
||||
ALICE,
|
||||
);
|
||||
|
||||
assert.equal(personas[0].id, "catalog:" + BOB + ":reviewer");
|
||||
assert.equal(personas[0].id, `catalog:${BOB}:reviewer`);
|
||||
assert.equal(personas[0].isActive, false);
|
||||
});
|
||||
|
||||
test("test_own_publication_still_resolves_by_local_id", () => {
|
||||
const publications = catalogPublicationsFromEvents([
|
||||
personaEvent({ createdAt: 1, id: "alice-reviewer" }),
|
||||
]);
|
||||
const publications = [publication()];
|
||||
const own = localPersona({ id: "reviewer", shared: true });
|
||||
|
||||
const personas = catalogPersonasFromPublications(publications, [own], ALICE);
|
||||
@@ -403,93 +157,3 @@ test("test_own_publication_still_resolves_by_local_id", () => {
|
||||
assert.equal(personas[0].id, "reviewer");
|
||||
assert.equal(personas[0].catalogSource.isOwn, true);
|
||||
});
|
||||
|
||||
function pageOfEvents(count, startId, createdAt) {
|
||||
return Array.from({ length: count }, (_, index) =>
|
||||
personaEvent({
|
||||
createdAt: typeof createdAt === "function" ? createdAt(index) : createdAt,
|
||||
id: `event-${startId + index}`,
|
||||
sourcePersonaId: `persona-${startId + index}`,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
function stubPagedRelay(pages) {
|
||||
const filters = [];
|
||||
mock.method(relayClient, "fetchEvents", (filter) => {
|
||||
filters.push(filter);
|
||||
return Promise.resolve(pages[filters.length - 1] ?? []);
|
||||
});
|
||||
return filters;
|
||||
}
|
||||
|
||||
// A single limit-capped fetch drops every entry past the relay's clamp, making
|
||||
// those agents undiscoverable. The walk must keep going while pages come back
|
||||
// full, and must carry an `until` cursor derived from the oldest event seen.
|
||||
test("test_full_page_is_followed_by_a_cursored_request_for_older_events", async (t) => {
|
||||
t.after(() => mock.restoreAll());
|
||||
const filters = stubPagedRelay([
|
||||
pageOfEvents(500, 0, (index) => 10_000 - index),
|
||||
pageOfEvents(3, 500, 9_000),
|
||||
]);
|
||||
|
||||
const publications = await fetchPersonaCatalogPublications();
|
||||
|
||||
assert.equal(filters.length, 2, "a full page must be followed by another");
|
||||
assert.equal(filters[0].until, undefined, "the first page has no cursor");
|
||||
assert.equal(
|
||||
filters[1].until,
|
||||
10_000 - 499,
|
||||
"the cursor must be the oldest created_at from the previous page",
|
||||
);
|
||||
assert.equal(
|
||||
publications.length,
|
||||
503,
|
||||
"entries past the first page must still be discoverable",
|
||||
);
|
||||
});
|
||||
|
||||
test("test_short_first_page_does_not_issue_a_second_request", async (t) => {
|
||||
t.after(() => mock.restoreAll());
|
||||
const filters = stubPagedRelay([pageOfEvents(2, 0, 10_000)]);
|
||||
|
||||
const publications = await fetchPersonaCatalogPublications();
|
||||
|
||||
assert.equal(filters.length, 1);
|
||||
assert.equal(publications.length, 2);
|
||||
});
|
||||
|
||||
// `until` is inclusive on the relay, so consecutive pages overlap on the
|
||||
// boundary timestamp. Without id dedupe the repeats would be counted twice.
|
||||
test("test_overlapping_pages_are_deduped_by_event_id", async (t) => {
|
||||
t.after(() => mock.restoreAll());
|
||||
const firstPage = pageOfEvents(500, 0, (index) => 10_000 - index);
|
||||
const secondPage = [
|
||||
// The boundary event repeats because `until` includes its timestamp.
|
||||
firstPage[firstPage.length - 1],
|
||||
...pageOfEvents(2, 500, 9_000),
|
||||
];
|
||||
stubPagedRelay([firstPage, secondPage]);
|
||||
|
||||
const publications = await fetchPersonaCatalogPublications();
|
||||
|
||||
assert.equal(publications.length, 502, "the repeated event must count once");
|
||||
});
|
||||
|
||||
// The stop-on-no-progress guard: a full page whose events all share one
|
||||
// created_at cannot advance the cursor, so paging must terminate instead of
|
||||
// re-requesting the same page forever.
|
||||
test("test_full_page_of_tied_timestamps_terminates_the_walk", async (t) => {
|
||||
t.after(() => mock.restoreAll());
|
||||
const tiedPage = pageOfEvents(500, 0, 10_000);
|
||||
const filters = stubPagedRelay([tiedPage, tiedPage, tiedPage, tiedPage]);
|
||||
|
||||
const publications = await fetchPersonaCatalogPublications();
|
||||
|
||||
assert.equal(
|
||||
filters.length,
|
||||
2,
|
||||
"the walk must stop once a page contributes nothing new",
|
||||
);
|
||||
assert.equal(publications.length, 500);
|
||||
});
|
||||
|
||||
@@ -1,11 +1,9 @@
|
||||
import { relayClient } from "@/shared/api/relayClient";
|
||||
import type {
|
||||
AgentPersona,
|
||||
CatalogSourceCoordinate,
|
||||
RelayEvent,
|
||||
RespondToMode,
|
||||
} from "@/shared/api/types";
|
||||
import { KIND_PERSONA } from "@/shared/constants/kinds";
|
||||
import { invokeTauri } from "@/shared/api/tauri";
|
||||
|
||||
export type CatalogPersonaShareLevel = "not-shared" | "none";
|
||||
|
||||
@@ -44,243 +42,15 @@ function isObject(value: unknown): value is JsonObject {
|
||||
return typeof value === "object" && value !== null && !Array.isArray(value);
|
||||
}
|
||||
|
||||
function extractTag(event: RelayEvent, name: string): string | null {
|
||||
const matches = event.tags.filter(
|
||||
(tag) => tag.length >= 2 && tag[0] === name && typeof tag[1] === "string",
|
||||
);
|
||||
return matches.length === 1 ? (matches[0]?.[1] ?? null) : null;
|
||||
}
|
||||
|
||||
export function personaEventIsShared(event: RelayEvent): boolean {
|
||||
const sharedTags = event.tags.filter((tag) => tag[0] === "shared");
|
||||
return (
|
||||
sharedTags.length === 1 &&
|
||||
sharedTags[0]?.length === 2 &&
|
||||
sharedTags[0]?.[1] === "true"
|
||||
);
|
||||
}
|
||||
|
||||
function isSafeHttpUrl(value: unknown): value is string {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length === 0 ||
|
||||
value.length > 2_048 ||
|
||||
/[\s()]/u.test(value)
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
try {
|
||||
const parsed = new URL(value);
|
||||
return parsed.protocol === "https:" || parsed.protocol === "http:";
|
||||
} catch {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Emoji avatars are the one `data:` avatar a catalog entry keeps.
|
||||
*
|
||||
* They persist as inline, percent-encoded SVG (`emojiAvatarDataUrl` in
|
||||
* `ProfileAvatarEditor.utils.ts`), so they are self-contained and render on
|
||||
* any member's machine — unlike a bundled runtime-default avatar, whose local
|
||||
* asset path means nothing to another install. The accepted shape is exactly
|
||||
* that prefix: the trailing comma is what rejects `;base64` payloads, and
|
||||
* every other `data:` MIME stays rejected. Catalog avatars render through
|
||||
* `<img src>` (`ProfileAvatar` → `AvatarImage`), where SVG script never
|
||||
* executes, so bounding the length is the remaining concern — 8 KiB is an
|
||||
* order of magnitude above the ~700 characters an emoji avatar encodes to.
|
||||
* Fetch the active community catalog through the shared native relay session.
|
||||
* Relay scoping, paging, signature verification, and head selection are native;
|
||||
* this boundary intentionally accepts no caller-supplied relay or identity.
|
||||
*/
|
||||
const INLINE_SVG_AVATAR_PREFIX = "data:image/svg+xml,";
|
||||
const MAX_INLINE_SVG_AVATAR_LENGTH = 8_192;
|
||||
|
||||
/**
|
||||
* Shared persona heads can carry an uploaded avatar as an inline raster. Keep
|
||||
* those self-contained images renderable without accepting arbitrary `data:`
|
||||
* URLs: only the raster MIME types browsers decode in `<img>`, strict base64
|
||||
* shape, and a bound no larger than the relay's event-content ceiling.
|
||||
*/
|
||||
const MAX_INLINE_RASTER_AVATAR_LENGTH = 256 * 1_024;
|
||||
const INLINE_RASTER_AVATAR_RE =
|
||||
/^data:image\/(?:png|jpeg|gif|webp);base64,([A-Za-z0-9+/]+={0,2})$/u;
|
||||
|
||||
function isInlineSvgAvatar(value: unknown): value is string {
|
||||
return (
|
||||
typeof value === "string" &&
|
||||
value.startsWith(INLINE_SVG_AVATAR_PREFIX) &&
|
||||
value.length <= MAX_INLINE_SVG_AVATAR_LENGTH
|
||||
);
|
||||
}
|
||||
|
||||
function isInlineRasterAvatar(value: unknown): value is string {
|
||||
if (
|
||||
typeof value !== "string" ||
|
||||
value.length > MAX_INLINE_RASTER_AVATAR_LENGTH
|
||||
) {
|
||||
return false;
|
||||
}
|
||||
const match = INLINE_RASTER_AVATAR_RE.exec(value);
|
||||
return match !== null && (match[1]?.length ?? 0) % 4 === 0;
|
||||
}
|
||||
|
||||
function optionalString(value: unknown): string | null {
|
||||
return typeof value === "string" && value.trim().length > 0 ? value : null;
|
||||
}
|
||||
|
||||
function parsePersonaContent(event: RelayEvent): CatalogAgentProjection | null {
|
||||
let parsed: unknown;
|
||||
try {
|
||||
parsed = JSON.parse(event.content);
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
if (
|
||||
!isObject(parsed) ||
|
||||
typeof parsed.display_name !== "string" ||
|
||||
parsed.display_name.trim().length === 0
|
||||
) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const avatarUrl =
|
||||
isSafeHttpUrl(parsed.avatar_url) ||
|
||||
isInlineSvgAvatar(parsed.avatar_url) ||
|
||||
isInlineRasterAvatar(parsed.avatar_url)
|
||||
? parsed.avatar_url
|
||||
: null;
|
||||
const namePool = Array.isArray(parsed.name_pool)
|
||||
? parsed.name_pool.filter(
|
||||
(candidate): candidate is string => typeof candidate === "string",
|
||||
)
|
||||
: [];
|
||||
const respondTo =
|
||||
parsed.respond_to === "allowlist"
|
||||
? "owner-only"
|
||||
: parsed.respond_to === "owner-only" || parsed.respond_to === "anyone"
|
||||
? parsed.respond_to
|
||||
: null;
|
||||
const parallelism =
|
||||
typeof parsed.parallelism === "number" &&
|
||||
Number.isInteger(parsed.parallelism) &&
|
||||
parsed.parallelism >= 1 &&
|
||||
parsed.parallelism <= 32
|
||||
? parsed.parallelism
|
||||
: null;
|
||||
|
||||
return {
|
||||
displayName: parsed.display_name,
|
||||
avatarUrl,
|
||||
systemPrompt:
|
||||
typeof parsed.system_prompt === "string" ? parsed.system_prompt : "",
|
||||
runtime: optionalString(parsed.runtime),
|
||||
model: optionalString(parsed.model),
|
||||
provider: optionalString(parsed.provider),
|
||||
namePool,
|
||||
respondTo,
|
||||
parallelism,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Collapse relay results to the canonical NIP-33 head for each persona
|
||||
* coordinate, then keep only exact `["shared", "true"]` heads.
|
||||
*
|
||||
* The relay normally returns one replaceable head. The client-side collapse is
|
||||
* defense in depth for older relays and fixtures, and deliberately claims the
|
||||
* coordinate before parsing so an invalid or unshared newest head cannot
|
||||
* resurrect an older shared definition.
|
||||
*/
|
||||
export function catalogPublicationsFromEvents(
|
||||
events: readonly RelayEvent[],
|
||||
): PersonaCatalogPublication[] {
|
||||
const sorted = [...events].sort(
|
||||
(left, right) =>
|
||||
right.created_at - left.created_at || left.id.localeCompare(right.id),
|
||||
);
|
||||
const seenCoordinates = new Set<string>();
|
||||
const publications: PersonaCatalogPublication[] = [];
|
||||
|
||||
for (const event of sorted) {
|
||||
if (event.kind !== KIND_PERSONA) continue;
|
||||
const sourcePersonaId = extractTag(event, "d");
|
||||
if (!sourcePersonaId) continue;
|
||||
const ownerPubkey = event.pubkey.toLowerCase();
|
||||
const coordinate = `${ownerPubkey}:${sourcePersonaId}`;
|
||||
if (seenCoordinates.has(coordinate)) continue;
|
||||
seenCoordinates.add(coordinate);
|
||||
|
||||
if (!personaEventIsShared(event)) continue;
|
||||
const agent = parsePersonaContent(event);
|
||||
if (!agent) continue;
|
||||
publications.push({
|
||||
eventId: event.id,
|
||||
ownerPubkey,
|
||||
sourcePersonaId,
|
||||
createdAt: event.created_at,
|
||||
agent,
|
||||
});
|
||||
}
|
||||
|
||||
return publications;
|
||||
}
|
||||
|
||||
/**
|
||||
* Events per catalog page.
|
||||
*
|
||||
* Kept well under the relay's 1,000-row `query_events` clamp so a page that
|
||||
* comes back full is a reliable "there may be more" signal rather than a
|
||||
* silently truncated result.
|
||||
*/
|
||||
const CATALOG_PAGE_SIZE = 500;
|
||||
|
||||
/**
|
||||
* Hard bound on pages walked, so a relay that keeps returning full pages can
|
||||
* never spin this forever.
|
||||
*/
|
||||
const MAX_CATALOG_PAGES = 40;
|
||||
|
||||
/**
|
||||
* Read every shared persona event, page by page.
|
||||
*
|
||||
* A single `limit`-capped fetch silently truncates once a community publishes
|
||||
* more agents than the relay's clamp, and the entries that fall off are simply
|
||||
* undiscoverable. Paging walks backwards through `created_at` using the only
|
||||
* cursor a WS `REQ` filter carries — `until` — which the relay treats as
|
||||
* *inclusive*, so consecutive pages overlap on tied timestamps. Two things
|
||||
* follow, and both are load-bearing:
|
||||
*
|
||||
* - dedupe by event id, because the boundary events repeat; and
|
||||
* - stop when a page contributes nothing new, because a page whose events all
|
||||
* share one `created_at` would otherwise be requested forever.
|
||||
*/
|
||||
export async function fetchPersonaCatalogPublications(): Promise<
|
||||
export function fetchPersonaCatalogPublications(): Promise<
|
||||
PersonaCatalogPublication[]
|
||||
> {
|
||||
const byId = new Map<string, RelayEvent>();
|
||||
let until: number | undefined;
|
||||
|
||||
for (let page = 0; page < MAX_CATALOG_PAGES; page += 1) {
|
||||
const events = await relayClient.fetchEvents({
|
||||
kinds: [KIND_PERSONA],
|
||||
limit: CATALOG_PAGE_SIZE,
|
||||
...(until === undefined ? {} : { until }),
|
||||
});
|
||||
|
||||
const sizeBefore = byId.size;
|
||||
let oldestCreatedAt = Number.POSITIVE_INFINITY;
|
||||
for (const event of events) {
|
||||
byId.set(event.id, event);
|
||||
oldestCreatedAt = Math.min(oldestCreatedAt, event.created_at);
|
||||
}
|
||||
|
||||
// A short page is the end of the catalog; a page of only-repeats means the
|
||||
// cursor cannot advance past a run of tied timestamps.
|
||||
if (events.length < CATALOG_PAGE_SIZE || byId.size === sizeBefore) {
|
||||
break;
|
||||
}
|
||||
until = oldestCreatedAt;
|
||||
}
|
||||
|
||||
return catalogPublicationsFromEvents([...byId.values()]);
|
||||
return invokeTauri<PersonaCatalogPublication[]>("fetch_persona_catalog");
|
||||
}
|
||||
|
||||
function publicationToPersona(
|
||||
|
||||
127
desktop/src/features/agents/lib/pickProfileAgent.test.mjs
Normal file
127
desktop/src/features/agents/lib/pickProfileAgent.test.mjs
Normal file
@@ -0,0 +1,127 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
pickDirectProfileAgent,
|
||||
pickProfileAgent,
|
||||
} from "./pickProfileAgent.ts";
|
||||
|
||||
const NONE_ARCHIVED = () => false;
|
||||
|
||||
function agent(overrides = {}) {
|
||||
return {
|
||||
name: "Instance",
|
||||
pubkey: "a".repeat(64),
|
||||
status: "stopped",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
test("the shared profile target prefers the active persona instance", () => {
|
||||
const stopped = agent({
|
||||
name: "Earlier instance",
|
||||
pubkey: "a".repeat(64),
|
||||
status: "stopped",
|
||||
});
|
||||
const running = agent({
|
||||
name: "Current instance",
|
||||
pubkey: "b".repeat(64),
|
||||
status: "running",
|
||||
});
|
||||
|
||||
assert.equal(pickProfileAgent([stopped, running], NONE_ARCHIVED), running);
|
||||
assert.equal(pickProfileAgent([running, stopped], NONE_ARCHIVED), running);
|
||||
});
|
||||
|
||||
test("an archived instance early in file order cannot hijack the target", () => {
|
||||
const archived = agent({
|
||||
name: "Archived instance",
|
||||
pubkey: "a".repeat(64),
|
||||
status: "running",
|
||||
});
|
||||
const live = agent({
|
||||
name: "Live instance",
|
||||
pubkey: "b".repeat(64),
|
||||
status: "stopped",
|
||||
});
|
||||
const isArchived = (pubkey) => pubkey === archived.pubkey;
|
||||
|
||||
// Archived is active AND first — without the filter it would win the sort.
|
||||
assert.equal(pickProfileAgent([archived, live], isArchived), live);
|
||||
assert.equal(pickProfileAgent([live, archived], isArchived), live);
|
||||
});
|
||||
|
||||
test("all instances archived yields undefined for persona-only mode", () => {
|
||||
const first = agent({ pubkey: "a".repeat(64) });
|
||||
const second = agent({ pubkey: "b".repeat(64) });
|
||||
|
||||
assert.equal(
|
||||
pickProfileAgent([first, second], () => true),
|
||||
undefined,
|
||||
);
|
||||
});
|
||||
|
||||
test("a fail-open predicate keeps every instance eligible while loading", () => {
|
||||
const stopped = agent({ pubkey: "a".repeat(64), status: "stopped" });
|
||||
const running = agent({ pubkey: "b".repeat(64), status: "running" });
|
||||
|
||||
// Fail-open (all false) during the archive-snapshot window: normal ranking.
|
||||
assert.equal(pickProfileAgent([stopped, running], NONE_ARCHIVED), running);
|
||||
});
|
||||
|
||||
test("a direct-opened active instance is never redirected to a sibling", () => {
|
||||
// "Alpha Sibling" sorts before "Tyler Agent"; without the direct guard an
|
||||
// access edit on Tyler would target the sibling.
|
||||
const sibling = {
|
||||
name: "Alpha Sibling",
|
||||
pubkey: "a".repeat(64),
|
||||
status: "running",
|
||||
};
|
||||
const clicked = {
|
||||
name: "Tyler Agent",
|
||||
pubkey: "b".repeat(64),
|
||||
status: "running",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
pickDirectProfileAgent(clicked, [sibling, clicked], NONE_ARCHIVED),
|
||||
clicked,
|
||||
);
|
||||
});
|
||||
|
||||
test("a direct-opened inactive instance redirects to the active sibling", () => {
|
||||
const historical = {
|
||||
name: "Earlier Parity Agent",
|
||||
pubkey: "a".repeat(64),
|
||||
status: "stopped",
|
||||
};
|
||||
const current = {
|
||||
name: "Current Parity Agent",
|
||||
pubkey: "b".repeat(64),
|
||||
status: "running",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
pickDirectProfileAgent(historical, [historical, current], NONE_ARCHIVED),
|
||||
current,
|
||||
);
|
||||
});
|
||||
|
||||
test("a direct-opened inactive instance with no active sibling stays put", () => {
|
||||
const clicked = {
|
||||
name: "Only Instance",
|
||||
pubkey: "a".repeat(64),
|
||||
status: "stopped",
|
||||
};
|
||||
const otherStopped = {
|
||||
name: "Another Stopped",
|
||||
pubkey: "b".repeat(64),
|
||||
status: "stopped",
|
||||
};
|
||||
|
||||
assert.equal(
|
||||
pickDirectProfileAgent(clicked, [clicked, otherStopped], NONE_ARCHIVED),
|
||||
clicked,
|
||||
);
|
||||
assert.equal(pickDirectProfileAgent(clicked, [], NONE_ARCHIVED), clicked);
|
||||
});
|
||||
52
desktop/src/features/agents/lib/pickProfileAgent.ts
Normal file
52
desktop/src/features/agents/lib/pickProfileAgent.ts
Normal file
@@ -0,0 +1,52 @@
|
||||
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
|
||||
import type { ManagedAgent } from "@/shared/api/types";
|
||||
|
||||
/**
|
||||
* Pick the instance that represents a persona throughout the UI.
|
||||
*
|
||||
* A persona can have several historical agent instances. Keeping this rule in
|
||||
* one place prevents an avatar click on an older message from opening a
|
||||
* different detail surface than the card in the Agents library.
|
||||
*
|
||||
* Relay-archived instances are never eligible, so an archived record early in
|
||||
* file order can't hijack the persona target. Returns `undefined` when every
|
||||
* instance is archived — the card then renders in persona-only mode. The
|
||||
* `isArchived` predicate is fail-open (returns `false` while the relay archive
|
||||
* snapshot loads), so a cold start never briefly picks nothing.
|
||||
*/
|
||||
export function pickProfileAgent(
|
||||
agents: readonly ManagedAgent[],
|
||||
isArchived: (pubkey: string) => boolean,
|
||||
) {
|
||||
return [...agents]
|
||||
.filter((agent) => !isArchived(agent.pubkey))
|
||||
.sort((left, right) => {
|
||||
const activeDiff =
|
||||
Number(isManagedAgentActive(right)) -
|
||||
Number(isManagedAgentActive(left));
|
||||
if (activeDiff !== 0) return activeDiff;
|
||||
return left.name.localeCompare(right.name);
|
||||
})[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve which instance a profile panel opened for `directAgent` should
|
||||
* show, given every instance of the same persona.
|
||||
*
|
||||
* Access edits must target the exact instance the user clicked — resolving a
|
||||
* running sidebar member to an alphabetically-earlier sibling would let a
|
||||
* "tighten access" save widen the wrong agent. But when the clicked instance
|
||||
* is inactive and the persona has an active instance elsewhere (an avatar on
|
||||
* an old message from a retired instance), redirect to the active one so the
|
||||
* panel matches the Agents library. The `isArchived` predicate keeps that
|
||||
* redirect from ever landing on an archived sibling.
|
||||
*/
|
||||
export function pickDirectProfileAgent(
|
||||
directAgent: ManagedAgent,
|
||||
personaInstances: readonly ManagedAgent[],
|
||||
isArchived: (pubkey: string) => boolean,
|
||||
) {
|
||||
if (isManagedAgentActive(directAgent)) return directAgent;
|
||||
const canonical = pickProfileAgent(personaInstances, isArchived);
|
||||
return canonical && isManagedAgentActive(canonical) ? canonical : directAgent;
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { relayAgentsQueryKey } from "@/features/agents/hooks";
|
||||
import { LOCAL_AGENT_DATA_QUERY_KEYS } from "./useAgentsDataRefresh.ts";
|
||||
|
||||
const serializedLocalKeys = LOCAL_AGENT_DATA_QUERY_KEYS.map((key) =>
|
||||
JSON.stringify(key),
|
||||
);
|
||||
|
||||
test("local agent refresh never invalidates the relay directory", () => {
|
||||
assert.equal(
|
||||
serializedLocalKeys.includes(JSON.stringify(relayAgentsQueryKey)),
|
||||
false,
|
||||
"local reconciliation must not trigger a relay-wide directory rebuild",
|
||||
);
|
||||
});
|
||||
@@ -5,23 +5,23 @@ import { useEffect } from "react";
|
||||
import {
|
||||
managedAgentsQueryKey,
|
||||
personasQueryKey,
|
||||
relayAgentsQueryKey,
|
||||
teamsQueryKey,
|
||||
} from "@/features/agents/hooks";
|
||||
import { managedAgentRuntimesQueryKey } from "@/features/agents/managedAgentRuntimeHooks";
|
||||
|
||||
// Trailing-coalesce window: a backfill burst (up to 500 inbound events fed
|
||||
// one-by-one through reconcile) fires one `agents-data-changed` per event.
|
||||
// Collapsing them into a single invalidate after the burst settles keeps the
|
||||
// refetch off React Query's implicit in-flight dedup and avoids redundant
|
||||
// disk-read IPC.
|
||||
export const LOCAL_AGENT_DATA_QUERY_KEYS = [
|
||||
personasQueryKey,
|
||||
teamsQueryKey,
|
||||
managedAgentsQueryKey,
|
||||
] as const;
|
||||
|
||||
// Trailing-coalesce local agent-store bursts into one cache refresh. The relay
|
||||
// directory is deliberately excluded: local persona/team/agent reconciliation
|
||||
// cannot change remote directory records, and rebuilding that directory is a
|
||||
// relay-wide operation. Remote data keeps its focused poll and is revalidated
|
||||
// directly before an agent mention is sent.
|
||||
const COALESCE_MS = 200;
|
||||
|
||||
// Invalidate the live Agents-tab queries when the backend signals that inbound
|
||||
// relay events changed the on-disk agents data. Mounted once at the app root
|
||||
// with empty deps — invalidation is global and has no reason to be
|
||||
// pubkey-scoped, so it must NOT live inside the pubkey-keyed `usePersonaSync`
|
||||
// (re-registering per identity switch would leak a listener each time).
|
||||
export function useAgentsDataRefresh(): void {
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
@@ -32,18 +32,15 @@ export function useAgentsDataRefresh(): void {
|
||||
void queryClient.invalidateQueries({
|
||||
queryKey: managedAgentRuntimesQueryKey,
|
||||
});
|
||||
// Pair startup also changes the legacy managed-agent scalar status.
|
||||
// Keep that cache synchronized for consumers outside pair-runtime UI.
|
||||
void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
|
||||
});
|
||||
|
||||
const unlisten = listen("agents-data-changed", () => {
|
||||
if (timer !== undefined) clearTimeout(timer);
|
||||
timer = setTimeout(() => {
|
||||
void queryClient.invalidateQueries({ queryKey: personasQueryKey });
|
||||
void queryClient.invalidateQueries({ queryKey: teamsQueryKey });
|
||||
void queryClient.invalidateQueries({ queryKey: managedAgentsQueryKey });
|
||||
void queryClient.invalidateQueries({ queryKey: relayAgentsQueryKey });
|
||||
for (const queryKey of LOCAL_AGENT_DATA_QUERY_KEYS) {
|
||||
void queryClient.invalidateQueries({ queryKey });
|
||||
}
|
||||
}, COALESCE_MS);
|
||||
});
|
||||
|
||||
|
||||
@@ -34,7 +34,7 @@ test("pickQuickBotPersonas prefers recents before defaults", () => {
|
||||
|
||||
test("pickQuickBotPersonas seeds the three starter agents", () => {
|
||||
const personas = [
|
||||
createPersona("builtin:bumble", "Bumble"),
|
||||
createPersona("builtin:bumble", "Pollen"),
|
||||
createPersona("builtin:honey", "Honey"),
|
||||
createPersona("builtin:fizz", "Fizz"),
|
||||
createPersona("builtin:reviewer", "Reviewer"),
|
||||
|
||||
@@ -7,7 +7,7 @@ const MAX_RECENTS = 8;
|
||||
|
||||
// Default persona display names to seed the list when empty.
|
||||
// These are resolved to IDs by the consumer.
|
||||
export const DEFAULT_PERSONA_NAMES = ["Fizz", "Honey", "Bumble"] as const;
|
||||
export const DEFAULT_PERSONA_NAMES = ["Fizz", "Honey", "Pollen"] as const;
|
||||
|
||||
export function pickQuickBotPersonas(
|
||||
personas: readonly AgentPersona[],
|
||||
|
||||
@@ -15,8 +15,12 @@ import {
|
||||
import type { AgentPersona, UpdatePersonaInput } from "@/shared/api/types";
|
||||
import { KIND_PERSONA } from "@/shared/constants/kinds";
|
||||
|
||||
/** Keeps focused polling at the established 2-minute backstop cadence. */
|
||||
export const PERSONA_CATALOG_REFETCH_INTERVAL_MS = 120_000;
|
||||
/** Poll backstop cadence. The live subscription (invalidate on any new
|
||||
* 30175) and the reconnect invalidation are the freshness paths; this poll
|
||||
* exists only to cover a silently dropped live event, so it can be rare. At
|
||||
* the previous 2-minute cadence it re-walked the entire persona catalog
|
||||
* (~150 KB burst) often enough to be a top-three desktop traffic source. */
|
||||
export const PERSONA_CATALOG_REFETCH_INTERVAL_MS = 20 * 60_000;
|
||||
/** Suppresses the focus refetch until persona catalog data is genuinely stale.
|
||||
* The live subscription (invalidateQueries) is the primary freshness path. */
|
||||
export const PERSONA_CATALOG_FOCUS_STALE_TIME_MS = 5 * 60_000;
|
||||
@@ -24,7 +28,7 @@ export const PERSONA_CATALOG_FOCUS_STALE_TIME_MS = 5 * 60_000;
|
||||
/** Focus-refetch policy for the persona catalog query; consumed by focusRefetchPolicy.test.mjs. */
|
||||
export const personaCatalogFocusRefetchPolicy = {
|
||||
staleTime: PERSONA_CATALOG_FOCUS_STALE_TIME_MS,
|
||||
refetchOnWindowFocus: true,
|
||||
refetchOnWindowFocus: false,
|
||||
} as const;
|
||||
|
||||
export function personaCatalogQueryKey(communityId: string | null) {
|
||||
|
||||
@@ -8,7 +8,10 @@ import {
|
||||
KIND_PERSONA,
|
||||
KIND_TEAM,
|
||||
} from "@/shared/constants/kinds";
|
||||
import { startPersonaSync } from "./usePersonaSync.ts";
|
||||
import {
|
||||
coalesceManagedAgentBackfill,
|
||||
startPersonaSync,
|
||||
} from "./usePersonaSync.ts";
|
||||
|
||||
const EXPECTED_KINDS = [
|
||||
KIND_PERSONA,
|
||||
@@ -17,6 +20,53 @@ const EXPECTED_KINDS = [
|
||||
KIND_DELETION,
|
||||
];
|
||||
|
||||
function event({
|
||||
id,
|
||||
kind = KIND_MANAGED_AGENT,
|
||||
createdAt,
|
||||
pubkey = "owner-pubkey",
|
||||
dTag = "agent-pubkey",
|
||||
}) {
|
||||
return {
|
||||
id,
|
||||
pubkey,
|
||||
created_at: createdAt,
|
||||
kind,
|
||||
tags: dTag ? [["d", dTag]] : [],
|
||||
content: "{}",
|
||||
sig: "sig",
|
||||
};
|
||||
}
|
||||
|
||||
test("startup backfill keeps only the newest managed-agent head per coordinate", () => {
|
||||
const persona = event({
|
||||
id: "persona",
|
||||
kind: KIND_PERSONA,
|
||||
createdAt: 1,
|
||||
dTag: "persona-id",
|
||||
});
|
||||
const otherAgent = event({
|
||||
id: "other-agent",
|
||||
createdAt: 2,
|
||||
dTag: "other-agent",
|
||||
});
|
||||
const oldest = event({ id: "oldest", createdAt: 1 });
|
||||
const sameSecondLoser = event({ id: "f", createdAt: 3 });
|
||||
const newest = event({ id: "a", createdAt: 3 });
|
||||
|
||||
assert.deepEqual(
|
||||
coalesceManagedAgentBackfill([
|
||||
oldest,
|
||||
persona,
|
||||
newest,
|
||||
otherAgent,
|
||||
sameSecondLoser,
|
||||
]).map(({ id }) => id),
|
||||
["persona", "a", "other-agent"],
|
||||
"NIP-33 uses newest created_at and lowest id on a tie",
|
||||
);
|
||||
});
|
||||
|
||||
// Regression guard for the fresh-start backfill gap (F3): a device that comes
|
||||
// online AFTER another published gets zero history from a live-only `limit: 0`
|
||||
// subscription, because reconnect-replay's since-cursor is undefined until the
|
||||
@@ -108,3 +158,46 @@ test("startPersonaSync forwards its own relay as the event arrival relay", async
|
||||
mock.reset();
|
||||
delete globalThis.window;
|
||||
});
|
||||
|
||||
test("startPersonaSync serializes inbound reconciliation in relay order", async () => {
|
||||
const resolvers = [];
|
||||
const invokedIds = [];
|
||||
globalThis.window = {
|
||||
__TAURI_INTERNALS__: {
|
||||
invoke: (_cmd, args) => {
|
||||
invokedIds.push(JSON.parse(args.eventJson).id);
|
||||
return new Promise((resolve) => resolvers.push(resolve));
|
||||
},
|
||||
},
|
||||
};
|
||||
|
||||
let onEvent;
|
||||
mock.method(relayClient, "fetchEvents", () => Promise.resolve([]));
|
||||
mock.method(relayClient, "subscribeLive", (_filter, listener) => {
|
||||
onEvent = listener;
|
||||
return Promise.resolve(() => Promise.resolve());
|
||||
});
|
||||
|
||||
startPersonaSync("owner-pubkey", "wss://community.example", () => false);
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
onEvent({ id: "broad", pubkey: "owner-pubkey", kind: KIND_MANAGED_AGENT });
|
||||
onEvent({
|
||||
id: "restricted",
|
||||
pubkey: "owner-pubkey",
|
||||
kind: KIND_MANAGED_AGENT,
|
||||
});
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
|
||||
assert.deepEqual(
|
||||
invokedIds,
|
||||
["broad"],
|
||||
"newer event waits for prior deployment",
|
||||
);
|
||||
resolvers.shift()();
|
||||
await new Promise((resolve) => setImmediate(resolve));
|
||||
assert.deepEqual(invokedIds, ["broad", "restricted"]);
|
||||
resolvers.shift()();
|
||||
|
||||
mock.reset();
|
||||
delete globalThis.window;
|
||||
});
|
||||
|
||||
@@ -20,6 +20,48 @@ const PERSONA_SYNC_KINDS = [
|
||||
KIND_DELETION,
|
||||
];
|
||||
|
||||
function eventDTag(event: RelayEvent): string | null {
|
||||
return event.tags.find((tag) => tag[0] === "d")?.[1] ?? null;
|
||||
}
|
||||
|
||||
function eventIsNewer(candidate: RelayEvent, current: RelayEvent): boolean {
|
||||
return (
|
||||
candidate.created_at > current.created_at ||
|
||||
(candidate.created_at === current.created_at && candidate.id < current.id)
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Keep only the NIP-33 head for each managed-agent coordinate in a startup
|
||||
* backfill. Applying historical policy revisions one by one can stop and start
|
||||
* the same runtime for every revision; the retained store only needs the final
|
||||
* head. Other event kinds stay in relay order because persona/team projections
|
||||
* do not trigger runtime policy transitions and deletion ordering is separate.
|
||||
*/
|
||||
export function coalesceManagedAgentBackfill(
|
||||
events: readonly RelayEvent[],
|
||||
): RelayEvent[] {
|
||||
const heads = new Map<string, RelayEvent>();
|
||||
|
||||
for (const event of events) {
|
||||
if (event.kind !== KIND_MANAGED_AGENT) continue;
|
||||
const dTag = eventDTag(event);
|
||||
if (!dTag) continue;
|
||||
const coordinate = `${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`;
|
||||
const current = heads.get(coordinate);
|
||||
if (!current || eventIsNewer(event, current)) heads.set(coordinate, event);
|
||||
}
|
||||
|
||||
return events.filter((event) => {
|
||||
if (event.kind !== KIND_MANAGED_AGENT) return true;
|
||||
const dTag = eventDTag(event);
|
||||
if (!dTag) return true;
|
||||
return (
|
||||
heads.get(`${event.pubkey.toLowerCase()}:${dTag.toLowerCase()}`) === event
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
// Start the persona/team/agent/deletion sync for `pubkey` on `relayUrl`:
|
||||
// one-shot backfill of existing heads + tombstones, then a live subscription.
|
||||
// Returns a disposer that closes the live subscription. Extracted from the hook
|
||||
@@ -35,13 +77,19 @@ export function startPersonaSync(
|
||||
relayUrl: string,
|
||||
onCancelled: () => boolean,
|
||||
): () => Promise<void> {
|
||||
// Reconcile in relay order. Managed-agent reconciliation can await a remote
|
||||
// provider deployment after releasing the local store lock; firing commands
|
||||
// independently lets an older broad policy finish after a newer restrictive
|
||||
// one. One chain per owner/relay subscription makes the newest event the last
|
||||
// deployment without serializing unrelated identities or communities.
|
||||
let reconcileChain = Promise.resolve();
|
||||
const reconcile = (event: RelayEvent) => {
|
||||
if (event.pubkey !== pubkey) return;
|
||||
void reconcileInboundPersonaEvent(JSON.stringify(event), relayUrl).catch(
|
||||
(error) => {
|
||||
reconcileChain = reconcileChain
|
||||
.then(() => reconcileInboundPersonaEvent(JSON.stringify(event), relayUrl))
|
||||
.catch((error) => {
|
||||
console.warn("[usePersonaSync] reconcile failed:", error);
|
||||
},
|
||||
);
|
||||
});
|
||||
};
|
||||
|
||||
// One-shot backfill of existing heads + tombstones (closes the fresh-start
|
||||
@@ -50,7 +98,8 @@ export function startPersonaSync(
|
||||
.fetchEvents({ kinds: PERSONA_SYNC_KINDS, authors: [pubkey], limit: 500 })
|
||||
.then((events) => {
|
||||
if (onCancelled()) return;
|
||||
for (const event of events) reconcile(event);
|
||||
for (const event of coalesceManagedAgentBackfill(events))
|
||||
reconcile(event);
|
||||
})
|
||||
.catch((error) => {
|
||||
console.warn("[usePersonaSync] backfill failed:", error);
|
||||
|
||||
@@ -27,6 +27,15 @@ import {
|
||||
} from "./ui/agentSessionTranscript";
|
||||
|
||||
const MAX_OBSERVER_EVENTS = 3000;
|
||||
// Length the per-agent journal is evicted down to when it overflows
|
||||
// MAX_OBSERVER_EVENTS. Eviction rebuilds the transcript from the retained
|
||||
// window (see appendAgentEvents), so trimming back to exactly the cap re-arms
|
||||
// eviction on the very next append — every steady-state append then replays the
|
||||
// whole history. Leaving 10% headroom amortizes one rebuild across the ~300
|
||||
// appends that refill it, while keeping the window within the cap. Expressed as
|
||||
// a fraction (not a fixed count) so the same math stays correct if the cap is
|
||||
// ever made per-agent, where a fixed headroom could exceed a smaller cap.
|
||||
const OBSERVER_EVENTS_LOW_WATER = Math.floor(MAX_OBSERVER_EVENTS * 0.9);
|
||||
const MAX_PENDING_UNKNOWN_AGENT_FRAMES = 100;
|
||||
|
||||
export type ObserverSnapshot = {
|
||||
@@ -44,11 +53,32 @@ const IDLE_SNAPSHOT: ObserverSnapshot = {
|
||||
const EMPTY_EVENTS: ObserverEvent[] = [];
|
||||
const EMPTY_TRANSCRIPT: TranscriptItem[] = [];
|
||||
|
||||
const listeners = new Set<() => void>();
|
||||
export type AgentObserverStoreUpdate = {
|
||||
agentPubkey: string;
|
||||
events: readonly ObserverEvent[];
|
||||
};
|
||||
|
||||
type AgentObserverStoreListener = (update?: AgentObserverStoreUpdate) => void;
|
||||
|
||||
const listeners = new Set<AgentObserverStoreListener>();
|
||||
const eventsByAgent = new Map<string, ObserverEvent[]>();
|
||||
const transcriptByAgent = new Map<string, TranscriptState>();
|
||||
const snapshotByAgent = new Map<string, ObserverSnapshot>();
|
||||
|
||||
// Per-agent eviction floor: the ordering key of the newest event that eviction
|
||||
// has ever discarded for this agent. Once the journal is trimmed to the
|
||||
// low-water mark, the dedup set (built only from the retained array) no longer
|
||||
// remembers the discarded frames, so a delayed/replayed relay frame at or below
|
||||
// that boundary would be re-admitted into the headroom — and a later refill to
|
||||
// the cap would then trim away 300 legitimate retained events with no new
|
||||
// activity. The floor rejects any arrival at or before it (equal included: the
|
||||
// floor event itself was evicted), so already-evicted history can never
|
||||
// re-enter. Cleared with the observer store; only advances forward.
|
||||
const evictionFloorByAgent = new Map<
|
||||
string,
|
||||
{ timestamp: string; seq: number }
|
||||
>();
|
||||
|
||||
// Channel-scoped archive event journal — holds paged history loaded from the local
|
||||
// SQLite archive without the MAX_OBSERVER_EVENTS live-relay cap. Keyed by
|
||||
// `${normalizedAgentPubkey}:${channelId}`. The live relay path writes to
|
||||
@@ -169,9 +199,9 @@ let startPromise: Promise<void> | null = null;
|
||||
let eventProcessingQueue: Promise<void> = Promise.resolve();
|
||||
let generation = 0;
|
||||
|
||||
function notifyListeners() {
|
||||
function notifyListeners(update?: AgentObserverStoreUpdate) {
|
||||
for (const listener of listeners) {
|
||||
listener();
|
||||
listener(update);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -196,41 +226,78 @@ function observerTag(event: RelayEvent, tagName: string) {
|
||||
function appendAgentEvents(
|
||||
agentPubkey: string,
|
||||
events: readonly ObserverEvent[],
|
||||
): boolean {
|
||||
if (events.length === 0) return false;
|
||||
): ObserverEvent[] | null {
|
||||
if (events.length === 0) return null;
|
||||
|
||||
const key = normalizePubkey(agentPubkey);
|
||||
const current = eventsByAgent.get(key) ?? [];
|
||||
const seen = new Set(
|
||||
current.map(
|
||||
(event) => `${event.timestamp.length}:${event.timestamp}:${event.seq}`,
|
||||
),
|
||||
);
|
||||
|
||||
// Reject any arrival at or before the eviction floor: those frames were
|
||||
// already discarded, so re-admitting them (they fit within the headroom
|
||||
// below the cap) would let a later refill trim away legitimate retained
|
||||
// events. Admit only frames strictly after the floor — the floor event
|
||||
// itself was evicted, so an equal ordering key is rejected too.
|
||||
const floor = evictionFloorByAgent.get(key);
|
||||
const admissible = floor
|
||||
? events.filter((event) => isObserverEventAfter(event, floor))
|
||||
: events;
|
||||
if (admissible.length === 0) return null;
|
||||
|
||||
// Ordinary live path: the harness publishes frames in order once per
|
||||
// second, so the whole batch lands strictly after the retained tail. In
|
||||
// that case no admissible event can collide with a retained one (the
|
||||
// journal is sorted), so dedup only needs to look inside the batch and the
|
||||
// merged journal is a plain concat — no Set over the full journal and no
|
||||
// whole-journal re-sort (whose comparator Date.parses per comparison).
|
||||
// Out-of-order or replayed arrivals take the full dedup + re-sort path.
|
||||
const currentLast = current.at(-1);
|
||||
const allAtEnd =
|
||||
!currentLast ||
|
||||
admissible.every((event) => isObserverEventAfter(event, currentLast));
|
||||
|
||||
const seen = allAtEnd
|
||||
? new Set<string>()
|
||||
: new Set(
|
||||
current.map(
|
||||
(event) =>
|
||||
`${event.timestamp.length}:${event.timestamp}:${event.seq}`,
|
||||
),
|
||||
);
|
||||
const added: ObserverEvent[] = [];
|
||||
for (const event of events) {
|
||||
for (const event of admissible) {
|
||||
const eventKey = `${event.timestamp.length}:${event.timestamp}:${event.seq}`;
|
||||
if (seen.has(eventKey)) continue;
|
||||
seen.add(eventKey);
|
||||
added.push(event);
|
||||
}
|
||||
if (added.length === 0) return false;
|
||||
if (added.length === 0) return null;
|
||||
|
||||
const sortedAdded = added.sort(compareObserverEvents);
|
||||
const sorted = [...current, ...sortedAdded].sort(compareObserverEvents);
|
||||
const sortedAdded = [...added].sort(compareObserverEvents);
|
||||
const sorted = allAtEnd
|
||||
? [...current, ...sortedAdded]
|
||||
: [...current, ...sortedAdded].sort(compareObserverEvents);
|
||||
const trimmed = sorted.length > MAX_OBSERVER_EVENTS;
|
||||
const final = trimmed
|
||||
? sorted.slice(sorted.length - MAX_OBSERVER_EVENTS)
|
||||
? sorted.slice(sorted.length - OBSERVER_EVENTS_LOW_WATER)
|
||||
: sorted;
|
||||
eventsByAgent.set(key, final);
|
||||
|
||||
// The common live path appends a sorted batch after the retained window. Fold
|
||||
// Record the newest event this trim discarded as the agent's eviction floor.
|
||||
// It is the entry just below the retained window; the floor only advances,
|
||||
// since the retained window is always the newest tail.
|
||||
if (trimmed) {
|
||||
const boundary = sorted[sorted.length - OBSERVER_EVENTS_LOW_WATER - 1];
|
||||
evictionFloorByAgent.set(key, {
|
||||
timestamp: boundary.timestamp,
|
||||
seq: boundary.seq,
|
||||
});
|
||||
}
|
||||
|
||||
// The common live path appends a sorted batch after the retained window
|
||||
// (the same `allAtEnd` that authorized the concat fast-path above). Fold
|
||||
// that batch through the transcript state once without rebuilding history.
|
||||
// Out-of-order arrivals and cap eviction rebuild from the final window so
|
||||
// stateful tool/permission relationships remain correct.
|
||||
const currentLast = current.at(-1);
|
||||
const allAtEnd =
|
||||
!currentLast ||
|
||||
sortedAdded.every((event) => compareObserverEvents(event, currentLast) > 0);
|
||||
if (allAtEnd && !trimmed) {
|
||||
let transcriptState =
|
||||
transcriptByAgent.get(key) ?? createEmptyTranscriptState();
|
||||
@@ -243,12 +310,24 @@ function appendAgentEvents(
|
||||
}
|
||||
|
||||
invalidateSnapshot(key);
|
||||
return true;
|
||||
if (!trimmed) return sortedAdded;
|
||||
|
||||
const retainedKeys = new Set(
|
||||
final.map(
|
||||
(event) => `${event.timestamp.length}:${event.timestamp}:${event.seq}`,
|
||||
),
|
||||
);
|
||||
return sortedAdded.filter((event) =>
|
||||
retainedKeys.has(
|
||||
`${event.timestamp.length}:${event.timestamp}:${event.seq}`,
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
function appendAgentEvent(agentPubkey: string, event: ObserverEvent) {
|
||||
if (appendAgentEvents(agentPubkey, [event])) {
|
||||
notifyListeners();
|
||||
const added = appendAgentEvents(agentPubkey, [event]);
|
||||
if (added) {
|
||||
notifyListeners({ agentPubkey, events: added });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -389,9 +468,19 @@ function processLiveObserverEvents(
|
||||
// callbacks. Those callbacks historically observed their triggering frame
|
||||
// in the raw/transcript stores; batching must preserve that visibility while
|
||||
// deferring only the global external-store publication.
|
||||
const observerChanged = appendAgentEvents(agentPubkey, events);
|
||||
//
|
||||
// Dispatch iterates the ACCEPTED events, not the raw envelope: the observer
|
||||
// relay requests a five-minute replay on reconnect, so an already-seen frame
|
||||
// can re-arrive. `appendAgentEvents` drops those as duplicates and returns
|
||||
// only the newly-accepted set; dispatching that set keeps a replayed
|
||||
// `control_result` from re-settling a live model switch, and likewise
|
||||
// prevents any other side-effect listener (latest-live tracking, management
|
||||
// requests, session-config capture, lifecycle) from firing twice for one
|
||||
// frame. Every such listener is a command or idempotent cache write — none
|
||||
// depends on duplicate re-delivery — so deduping is strictly correct.
|
||||
const accepted = appendAgentEvents(agentPubkey, events);
|
||||
|
||||
for (const parsed of events) {
|
||||
for (const parsed of accepted ?? []) {
|
||||
// Track the latest-live-session-id per (agent, channel) on the live path.
|
||||
// Only set when the parsed event carries both a sessionId and channelId,
|
||||
// so we never attribute a session to the wrong channel.
|
||||
@@ -421,7 +510,9 @@ function processLiveObserverEvents(
|
||||
void putAgentSessionConfig(agentPubkey, parsed.payload);
|
||||
onSessionConfigCaptured?.(agentPubkey);
|
||||
} else if (parsed.kind === "control_result") {
|
||||
dispatchControlResult(agentPubkey, parsed.payload);
|
||||
// Thread the envelope's channelId into the frame so the ModelPicker can
|
||||
// count a terminal switch result once per distinct channel.
|
||||
dispatchControlResult(agentPubkey, parsed.payload, parsed.channelId);
|
||||
} else if (parsed.kind === "managed_agent_runtime_lifecycle") {
|
||||
void putManagedAgentRuntimeLifecycle(agentPubkey, parsed.payload).catch(
|
||||
(error) => {
|
||||
@@ -433,8 +524,8 @@ function processLiveObserverEvents(
|
||||
|
||||
// Preserve the harness's envelope backpressure: retained state was committed
|
||||
// before specialized callbacks, but external-store subscribers publish once.
|
||||
if (observerChanged) {
|
||||
notifyListeners();
|
||||
if (accepted) {
|
||||
notifyListeners({ agentPubkey, events: accepted });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -542,7 +633,9 @@ export function ensureRelayObserverSubscription() {
|
||||
return startPromise;
|
||||
}
|
||||
|
||||
export function subscribeAgentObserverStore(listener: () => void) {
|
||||
export function subscribeAgentObserverStore(
|
||||
listener: AgentObserverStoreListener,
|
||||
) {
|
||||
listeners.add(listener);
|
||||
return () => {
|
||||
listeners.delete(listener);
|
||||
@@ -558,7 +651,11 @@ function isControlResultFrame(payload: unknown): payload is ControlResultFrame {
|
||||
);
|
||||
}
|
||||
|
||||
function dispatchControlResult(agentPubkey: string, payload: unknown) {
|
||||
function dispatchControlResult(
|
||||
agentPubkey: string,
|
||||
payload: unknown,
|
||||
channelId: string | null,
|
||||
) {
|
||||
if (!isControlResultFrame(payload)) {
|
||||
return;
|
||||
}
|
||||
@@ -566,8 +663,13 @@ function dispatchControlResult(agentPubkey: string, payload: unknown) {
|
||||
if (!subscribers) {
|
||||
return;
|
||||
}
|
||||
// The channelId lives on the observer envelope, not the inner payload, so
|
||||
// stamp it onto the frame here. Listeners (the ModelPicker) count a terminal
|
||||
// switch result once per distinct channel; the envelope is the only place a
|
||||
// late `control_result` carries its channel identity.
|
||||
const frame: ControlResultFrame = { ...payload, channelId };
|
||||
for (const subscriber of subscribers) {
|
||||
subscriber(payload);
|
||||
subscriber(frame);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -781,8 +883,9 @@ export function injectObserverEventsForE2E(
|
||||
agentPubkey: string,
|
||||
events: ObserverEvent[],
|
||||
) {
|
||||
if (appendAgentEvents(agentPubkey, events)) {
|
||||
notifyListeners();
|
||||
const added = appendAgentEvents(agentPubkey, events);
|
||||
if (added) {
|
||||
notifyListeners({ agentPubkey, events: added });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -794,8 +897,9 @@ export function syncAgentObserverEvents(
|
||||
agentPubkey: string,
|
||||
events: ObserverEvent[],
|
||||
) {
|
||||
if (appendAgentEvents(agentPubkey, events)) {
|
||||
notifyListeners();
|
||||
const added = appendAgentEvents(agentPubkey, events);
|
||||
if (added) {
|
||||
notifyListeners({ agentPubkey, events: added });
|
||||
}
|
||||
}
|
||||
|
||||
@@ -807,6 +911,7 @@ export function resetAgentObserverStore() {
|
||||
eventProcessingQueue = Promise.resolve();
|
||||
eventsByAgent.clear();
|
||||
transcriptByAgent.clear();
|
||||
evictionFloorByAgent.clear();
|
||||
snapshotByAgent.clear();
|
||||
archiveEventsByChannel.clear();
|
||||
knownAgentPubkeys.clear();
|
||||
|
||||
418
desktop/src/features/agents/observerTranscriptRetention.test.mjs
Normal file
418
desktop/src/features/agents/observerTranscriptRetention.test.mjs
Normal file
@@ -0,0 +1,418 @@
|
||||
/**
|
||||
* Retention behavior of the per-agent live observer journal.
|
||||
*
|
||||
* `appendAgentEvents` derives the transcript incrementally when a batch lands
|
||||
* after the retained window, and falls back to a full `buildTranscriptState`
|
||||
* replay when the window is evicted. Evicting back to *exactly* the cap made
|
||||
* that fallback permanent: an agent parked at the cap evicts one event on every
|
||||
* append, so `trimmed` is true forever and every steady-state append replays the
|
||||
* whole history through `buildTranscriptState` (issue #5718: 188x headless,
|
||||
* live CPU escalating to 119%/core after 5min idle).
|
||||
*
|
||||
* The fix evicts to a low-water mark below the cap, so the window must refill
|
||||
* through ordinary appends before the next eviction — one replay amortized
|
||||
* across the refill. Transcript content is identical on the fold and rebuild
|
||||
* paths (that is the point of the fallback), so these tests assert the
|
||||
* observable that distinguishes them: the retained window's SHAPE after
|
||||
* eviction. They also pin the invariant that the derived transcript still equals
|
||||
* a full replay of the retained window regardless of which path ran.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { beforeEach, describe, it } from "node:test";
|
||||
|
||||
import {
|
||||
getAgentObserverSnapshot,
|
||||
getAgentTranscript,
|
||||
resetAgentObserverStore,
|
||||
subscribeAgentObserverStore,
|
||||
syncAgentObserverEvents,
|
||||
} from "@/features/agents/observerRelayStore.ts";
|
||||
import { buildTranscript } from "@/features/agents/ui/agentSessionTranscript.ts";
|
||||
|
||||
// Mirrors the private constants in observerRelayStore.ts. LOW_WATER is
|
||||
// Math.floor(MAX * 0.9); the tests assert exact shapes against these values so
|
||||
// a regression in the eviction math (e.g. reverting to trim-to-cap) fails here.
|
||||
const MAX_OBSERVER_EVENTS = 3000;
|
||||
const OBSERVER_EVENTS_LOW_WATER = Math.floor(MAX_OBSERVER_EVENTS * 0.9);
|
||||
|
||||
const AGENT_PUBKEY = "a".repeat(64);
|
||||
|
||||
/** One live observer event; monotonic timestamp keyed to seq so the store's
|
||||
* timestamp-then-seq sort matches insertion order. */
|
||||
function makeEvent(seq) {
|
||||
return {
|
||||
seq,
|
||||
timestamp: new Date(1_760_000_000_000 + seq * 1000).toISOString(),
|
||||
kind: "turn_started",
|
||||
agentIndex: 0,
|
||||
channelId: "chan-1",
|
||||
sessionId: "sess-1",
|
||||
turnId: `turn-${seq}`,
|
||||
payload: {},
|
||||
};
|
||||
}
|
||||
|
||||
function windowLength() {
|
||||
return getAgentObserverSnapshot(AGENT_PUBKEY).events.length;
|
||||
}
|
||||
|
||||
/** Append events seq 1..count one at a time, mirroring the live relay path
|
||||
* where each frame appends and notifies individually. */
|
||||
function fillSequential(count) {
|
||||
for (let seq = 1; seq <= count; seq += 1) {
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(seq)]);
|
||||
}
|
||||
}
|
||||
|
||||
describe("live observer journal retention — amortized eviction", () => {
|
||||
beforeEach(() => {
|
||||
resetAgentObserverStore();
|
||||
});
|
||||
|
||||
it("test_window_never_exceeds_cap", () => {
|
||||
for (let seq = 1; seq <= MAX_OBSERVER_EVENTS + 750; seq += 1) {
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(seq)]);
|
||||
assert.ok(
|
||||
windowLength() <= MAX_OBSERVER_EVENTS,
|
||||
`window grew to ${windowLength()} at seq ${seq}`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
it("test_append_at_cap_does_not_trim_prematurely", () => {
|
||||
// Filling to exactly the cap must NOT evict — `trimmed` is `length > cap`,
|
||||
// and length === cap is not over. A premature trim here would mean the
|
||||
// fraction math or the comparison regressed.
|
||||
fillSequential(MAX_OBSERVER_EVENTS);
|
||||
assert.equal(
|
||||
windowLength(),
|
||||
MAX_OBSERVER_EVENTS,
|
||||
"reaching exactly the cap retains the full window, no eviction",
|
||||
);
|
||||
});
|
||||
|
||||
it("test_append_crossing_cap_trims_to_exactly_low_water", () => {
|
||||
// The append that pushes past the cap must leave the window at exactly the
|
||||
// low-water mark — not back at the cap (which would re-arm eviction on the
|
||||
// very next append and keep the transcript rebuilding forever).
|
||||
fillSequential(MAX_OBSERVER_EVENTS);
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(MAX_OBSERVER_EVENTS + 1)]);
|
||||
|
||||
assert.equal(
|
||||
windowLength(),
|
||||
OBSERVER_EVENTS_LOW_WATER,
|
||||
"crossing the cap trims to exactly the low-water mark, leaving headroom",
|
||||
);
|
||||
assert.ok(
|
||||
windowLength() < MAX_OBSERVER_EVENTS,
|
||||
"headroom exists below the cap after eviction",
|
||||
);
|
||||
});
|
||||
|
||||
it("test_headroom_refills_before_next_eviction", () => {
|
||||
// After the first eviction leaves headroom, subsequent appends must GROW
|
||||
// the window (no eviction) until it refills to the cap — proving eviction
|
||||
// is amortized across the refill, not per-append.
|
||||
fillSequential(MAX_OBSERVER_EVENTS + 1);
|
||||
assert.equal(windowLength(), OBSERVER_EVENTS_LOW_WATER);
|
||||
|
||||
const headroom = MAX_OBSERVER_EVENTS - OBSERVER_EVENTS_LOW_WATER;
|
||||
for (let i = 1; i <= headroom; i += 1) {
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, [
|
||||
makeEvent(MAX_OBSERVER_EVENTS + 1 + i),
|
||||
]);
|
||||
assert.equal(
|
||||
windowLength(),
|
||||
OBSERVER_EVENTS_LOW_WATER + i,
|
||||
`append ${i} into the headroom must grow the window, not evict`,
|
||||
);
|
||||
}
|
||||
// The window is now back at the cap; the next append evicts again.
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, [
|
||||
makeEvent(MAX_OBSERVER_EVENTS + 2 + headroom),
|
||||
]);
|
||||
assert.equal(
|
||||
windowLength(),
|
||||
OBSERVER_EVENTS_LOW_WATER,
|
||||
"the window only evicts again after the headroom is refilled",
|
||||
);
|
||||
});
|
||||
|
||||
it("test_eviction_keeps_newest_events_drops_oldest", () => {
|
||||
const total = MAX_OBSERVER_EVENTS + 400;
|
||||
fillSequential(total);
|
||||
const events = getAgentObserverSnapshot(AGENT_PUBKEY).events;
|
||||
assert.equal(events.at(-1).seq, total, "newest event is retained");
|
||||
assert.equal(
|
||||
events.at(0).seq,
|
||||
total - events.length + 1,
|
||||
"retention is the newest-N contiguous tail",
|
||||
);
|
||||
});
|
||||
|
||||
it("test_derived_transcript_equals_full_replay_after_eviction", () => {
|
||||
// The rebuild fallback and the incremental fold must agree: after crossing
|
||||
// the cap (rebuild path) the stored transcript equals a fresh replay of the
|
||||
// retained window.
|
||||
fillSequential(MAX_OBSERVER_EVENTS + 600);
|
||||
const retained = getAgentObserverSnapshot(AGENT_PUBKEY).events;
|
||||
assert.deepEqual(
|
||||
getAgentTranscript(AGENT_PUBKEY),
|
||||
buildTranscript(retained),
|
||||
"the derived transcript matches a full replay of the retained window",
|
||||
);
|
||||
});
|
||||
|
||||
it("test_single_batch_larger_than_cap_trims_to_low_water", () => {
|
||||
const batch = [];
|
||||
for (let seq = 1; seq <= MAX_OBSERVER_EVENTS + 900; seq += 1) {
|
||||
batch.push(makeEvent(seq));
|
||||
}
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, batch);
|
||||
assert.equal(
|
||||
windowLength(),
|
||||
OBSERVER_EVENTS_LOW_WATER,
|
||||
"a single over-cap batch also trims to the low-water mark",
|
||||
);
|
||||
assert.equal(
|
||||
getAgentObserverSnapshot(AGENT_PUBKEY).events.at(-1).seq,
|
||||
MAX_OBSERVER_EVENTS + 900,
|
||||
"the newest event of an over-cap batch is retained",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("live observer journal retention — eviction floor (reconnect replay)", () => {
|
||||
// The dedup set is built only from the retained array, so once eviction
|
||||
// discards the oldest frames the journal no longer remembers them. Relay
|
||||
// reconnect replays old frames as a normal behavior; without a floor a
|
||||
// replayed pre-eviction frame is re-admitted into the headroom and a later
|
||||
// refill to the cap trims away legitimate retained events with no new
|
||||
// activity. These pin the floor that rejects already-evicted history.
|
||||
beforeEach(() => {
|
||||
resetAgentObserverStore();
|
||||
});
|
||||
|
||||
it("test_replayed_pre_floor_frames_do_not_change_retained_window", () => {
|
||||
// Overflow to the low-water mark, then replay the discarded oldest frames.
|
||||
// They are at or below the eviction floor, so none is re-admitted: the
|
||||
// retained window is byte-identical and no listener is notified.
|
||||
fillSequential(MAX_OBSERVER_EVENTS + 1);
|
||||
const before = getAgentObserverSnapshot(AGENT_PUBKEY).events;
|
||||
assert.equal(before.length, OBSERVER_EVENTS_LOW_WATER);
|
||||
const beforeFirstSeq = before.at(0).seq;
|
||||
|
||||
let notifications = 0;
|
||||
const unsubscribe = subscribeAgentObserverStore(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
try {
|
||||
// seq 1..(beforeFirstSeq - 1) were discarded; replay a spread of them
|
||||
// plus the boundary event itself (beforeFirstSeq - 1 is the floor).
|
||||
for (let seq = 1; seq < beforeFirstSeq; seq += 1) {
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(seq)]);
|
||||
}
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
|
||||
const after = getAgentObserverSnapshot(AGENT_PUBKEY).events;
|
||||
assert.deepEqual(
|
||||
after,
|
||||
before,
|
||||
"replaying already-evicted frames must not change the retained window",
|
||||
);
|
||||
assert.equal(
|
||||
notifications,
|
||||
0,
|
||||
"a stale-only replay does no work and notifies no listener",
|
||||
);
|
||||
});
|
||||
|
||||
it("test_pre_floor_frame_after_refill_drops_no_retained_events", () => {
|
||||
// Overflow to low-water, refill to the cap through ordinary appends, then
|
||||
// inject a single pre-floor (already-evicted) frame. Pre-fix this pushed
|
||||
// length to cap+1 and trimmed away 300 legitimate retained events; the
|
||||
// floor now rejects it, so the retained window is untouched.
|
||||
fillSequential(MAX_OBSERVER_EVENTS + 1);
|
||||
const floorBoundarySeq =
|
||||
getAgentObserverSnapshot(AGENT_PUBKEY).events.at(0).seq;
|
||||
const headroom = MAX_OBSERVER_EVENTS - OBSERVER_EVENTS_LOW_WATER;
|
||||
for (let i = 1; i <= headroom; i += 1) {
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, [
|
||||
makeEvent(MAX_OBSERVER_EVENTS + 1 + i),
|
||||
]);
|
||||
}
|
||||
const atCap = getAgentObserverSnapshot(AGENT_PUBKEY).events;
|
||||
assert.equal(atCap.length, MAX_OBSERVER_EVENTS, "refilled back to the cap");
|
||||
|
||||
// A pre-floor frame (seq strictly below the boundary that was evicted).
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(floorBoundarySeq - 1)]);
|
||||
|
||||
const after = getAgentObserverSnapshot(AGENT_PUBKEY).events;
|
||||
assert.equal(
|
||||
after.length,
|
||||
MAX_OBSERVER_EVENTS,
|
||||
"a rejected pre-floor frame must not trigger a trim below the cap",
|
||||
);
|
||||
assert.deepEqual(
|
||||
after,
|
||||
atCap,
|
||||
"no legitimate retained event is dropped by a pre-floor arrival",
|
||||
);
|
||||
});
|
||||
|
||||
it("test_out_of_order_frame_newer_than_floor_is_still_admitted", () => {
|
||||
// The floor must reject only already-evicted history, never a legitimate
|
||||
// out-of-order frame that sorts after the floor. Such a frame lands in the
|
||||
// retained window (via the rebuild fallback) and advances the length.
|
||||
fillSequential(MAX_OBSERVER_EVENTS + 1);
|
||||
const retained = getAgentObserverSnapshot(AGENT_PUBKEY).events;
|
||||
const oldestRetainedSeq = retained.at(0).seq;
|
||||
const lengthBefore = retained.length;
|
||||
|
||||
// The eviction floor is the boundary event just below the retained window
|
||||
// (seq oldestRetainedSeq - 1). Construct a never-seen frame whose timestamp
|
||||
// sits strictly between the floor and the oldest retained event — out of
|
||||
// order versus the tail, but newer than the floor, so it must be admitted.
|
||||
const floorSeq = oldestRetainedSeq - 1;
|
||||
const oooEvent = {
|
||||
seq: 1_000_000_000,
|
||||
timestamp: new Date(
|
||||
1_760_000_000_000 + floorSeq * 1000 + 500,
|
||||
).toISOString(),
|
||||
kind: "turn_started",
|
||||
agentIndex: 0,
|
||||
channelId: "chan-1",
|
||||
sessionId: "sess-1",
|
||||
turnId: "turn-ooo",
|
||||
payload: {},
|
||||
};
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, [oooEvent]);
|
||||
|
||||
const after = getAgentObserverSnapshot(AGENT_PUBKEY).events;
|
||||
assert.equal(
|
||||
after.length,
|
||||
lengthBefore + 1,
|
||||
"an out-of-order frame newer than the floor is admitted, not rejected",
|
||||
);
|
||||
assert.ok(
|
||||
after.some((event) => event.seq === oooEvent.seq),
|
||||
"the admitted frame is present in the retained window",
|
||||
);
|
||||
});
|
||||
});
|
||||
|
||||
describe("live observer journal — in-order append fast path ordering/dedup", () => {
|
||||
// The common live path (every batch strictly after the retained tail) skips
|
||||
// the whole-journal dedup Set and re-sort. These pin the observable
|
||||
// invariants that authorize that skip: identical ordering, dedup, and
|
||||
// transcript against the general path.
|
||||
beforeEach(() => {
|
||||
resetAgentObserverStore();
|
||||
});
|
||||
|
||||
/** An event with an explicit timestamp, for equal-timestamp tie-breaks. */
|
||||
function makeEventAt(seq, timestampMs) {
|
||||
return {
|
||||
...makeEvent(seq),
|
||||
timestamp: new Date(timestampMs).toISOString(),
|
||||
};
|
||||
}
|
||||
|
||||
it("test_equal_timestamp_batch_orders_by_seq", () => {
|
||||
// A one-second harness frame batches several events sharing a timestamp;
|
||||
// the tie-break is seq. Deliver them out of seq order in one batch.
|
||||
const t = 1_760_000_100_000;
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, [makeEventAt(1, t - 1000)]);
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, [
|
||||
makeEventAt(4, t),
|
||||
makeEventAt(2, t),
|
||||
makeEventAt(3, t),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
getAgentObserverSnapshot(AGENT_PUBKEY).events.map((event) => event.seq),
|
||||
[1, 2, 3, 4],
|
||||
"equal-timestamp events are retained in seq order",
|
||||
);
|
||||
});
|
||||
|
||||
it("test_duplicate_batch_redelivery_is_ignored", () => {
|
||||
// Relay redelivery of the newest batch: every event duplicates the tail,
|
||||
// so nothing is admitted, nothing is notified.
|
||||
const batch = [makeEvent(1), makeEvent(2), makeEvent(3)];
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, batch);
|
||||
let notifications = 0;
|
||||
const unsubscribe = subscribeAgentObserverStore(() => {
|
||||
notifications += 1;
|
||||
});
|
||||
try {
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, batch);
|
||||
} finally {
|
||||
unsubscribe();
|
||||
}
|
||||
assert.deepEqual(
|
||||
getAgentObserverSnapshot(AGENT_PUBKEY).events.map((event) => event.seq),
|
||||
[1, 2, 3],
|
||||
"a redelivered batch adds nothing",
|
||||
);
|
||||
assert.equal(notifications, 0, "a pure-duplicate batch notifies no one");
|
||||
});
|
||||
|
||||
it("test_batch_with_intra_batch_duplicate_admits_once", () => {
|
||||
// A batch strictly after the tail still dedups within itself.
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, [makeEvent(1)]);
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, [
|
||||
makeEvent(2),
|
||||
makeEvent(3),
|
||||
makeEvent(2),
|
||||
]);
|
||||
assert.deepEqual(
|
||||
getAgentObserverSnapshot(AGENT_PUBKEY).events.map((event) => event.seq),
|
||||
[1, 2, 3],
|
||||
"an intra-batch duplicate is admitted exactly once",
|
||||
);
|
||||
});
|
||||
|
||||
it("test_late_arrival_overlapping_tail_takes_slow_path_and_dedups", () => {
|
||||
// A replayed window straddling the tail: partly duplicate, partly new,
|
||||
// partly older-than-tail. Not all-after, so the general path must dedup
|
||||
// against the whole journal and re-sort.
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, [
|
||||
makeEvent(1),
|
||||
makeEvent(2),
|
||||
makeEvent(4),
|
||||
]);
|
||||
syncAgentObserverEvents(AGENT_PUBKEY, [
|
||||
makeEvent(2),
|
||||
makeEvent(3),
|
||||
makeEvent(4),
|
||||
makeEvent(5),
|
||||
]);
|
||||
const events = getAgentObserverSnapshot(AGENT_PUBKEY).events;
|
||||
assert.deepEqual(
|
||||
events.map((event) => event.seq),
|
||||
[1, 2, 3, 4, 5],
|
||||
"overlapping late arrival dedups against the journal and sorts into place",
|
||||
);
|
||||
assert.deepEqual(
|
||||
getAgentTranscript(AGENT_PUBKEY),
|
||||
buildTranscript(events),
|
||||
"the transcript equals a full replay after a mixed-path sequence",
|
||||
);
|
||||
});
|
||||
|
||||
it("test_fast_path_transcript_equals_full_replay", () => {
|
||||
// Pure in-order streaming (fast path every time) must produce the same
|
||||
// derived transcript as a replay of the retained window.
|
||||
fillSequential(50);
|
||||
const events = getAgentObserverSnapshot(AGENT_PUBKEY).events;
|
||||
assert.equal(events.length, 50);
|
||||
assert.deepEqual(
|
||||
getAgentTranscript(AGENT_PUBKEY),
|
||||
buildTranscript(events),
|
||||
"in-order fast-path appends derive the same transcript as a replay",
|
||||
);
|
||||
});
|
||||
});
|
||||
@@ -143,7 +143,10 @@ function AgentCardViewerContent({
|
||||
toast.success(`Sent ${agentName}'s card.`);
|
||||
closeCardViewer();
|
||||
} else if (sent === false) {
|
||||
toast.error("Couldn’t send the card. Try again.");
|
||||
toast.error(
|
||||
sendController.getCurrentError() ??
|
||||
"Couldn’t send the card. Try again.",
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -48,6 +48,7 @@ import {
|
||||
} from "@/features/agents/ui/agentConfigControls";
|
||||
import { PersonaProviderApiKeyField } from "@/features/agents/ui/PersonaProviderApiKeyField";
|
||||
import { usePersonaModelDiscovery } from "@/features/agents/ui/usePersonaModelDiscovery";
|
||||
import { resolveModelLabel } from "@/features/agents/lib/formatAgentModelLabel";
|
||||
import {
|
||||
BUZZ_AGENT_THINKING_EFFORT,
|
||||
getProviderEffortConfig,
|
||||
@@ -800,7 +801,9 @@ export function AgentConfigFields({
|
||||
<AgentModelField
|
||||
allowDefaultModel={fallbackModel !== null}
|
||||
defaultModelLabel={
|
||||
fallbackModel ? `Default model (${fallbackModel})` : undefined
|
||||
fallbackModel
|
||||
? `Default model (${resolveModelLabel(fallbackModel, undefined, effectiveProvider || undefined)})`
|
||||
: undefined
|
||||
}
|
||||
disableSelectDuringDiscovery={disableModelSelectDuringDiscovery}
|
||||
disabled={dependentFieldsDisabled}
|
||||
|
||||
@@ -10,7 +10,6 @@ import {
|
||||
Layers,
|
||||
MessageSquare,
|
||||
Pencil,
|
||||
PenOff,
|
||||
Server,
|
||||
} from "lucide-react";
|
||||
import { useAgentConfigSurface } from "../hooks";
|
||||
@@ -25,7 +24,6 @@ import { McpServersSection, shouldRenderMcpServers } from "./McpServersSection";
|
||||
import type {
|
||||
ConfigField,
|
||||
ConfigOrigin,
|
||||
ConfigWriteMechanism,
|
||||
NormalizedConfig,
|
||||
NormalizedField,
|
||||
RuntimeConfigSurface,
|
||||
@@ -54,16 +52,6 @@ type AgentConfigSurfaceRowsProps = {
|
||||
sections?: readonly AgentConfigPanelSection[];
|
||||
};
|
||||
|
||||
function isReadOnlyField({
|
||||
origin,
|
||||
writeVia,
|
||||
}: {
|
||||
origin: ConfigOrigin;
|
||||
writeVia: ConfigWriteMechanism;
|
||||
}) {
|
||||
return writeVia.type === "readOnly" || origin === "harnessConstraint";
|
||||
}
|
||||
|
||||
function ConfigFieldLabel({ label }: { label: string }) {
|
||||
return (
|
||||
<span className="inline-flex min-w-0 items-center gap-1.5 text-sm font-medium text-foreground">
|
||||
@@ -72,25 +60,6 @@ function ConfigFieldLabel({ label }: { label: string }) {
|
||||
);
|
||||
}
|
||||
|
||||
function ProvenanceHint({
|
||||
locked,
|
||||
provenance,
|
||||
showLockIcon = true,
|
||||
}: {
|
||||
locked: boolean;
|
||||
provenance: string;
|
||||
showLockIcon?: boolean;
|
||||
}) {
|
||||
return (
|
||||
<span className="mt-0.5 flex items-center gap-1 text-2xs text-muted-foreground/70">
|
||||
{locked && showLockIcon ? (
|
||||
<PenOff aria-label="Read-only" className="h-3 w-3 shrink-0" />
|
||||
) : null}
|
||||
<span className="min-w-0 truncate">{provenance}</span>
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
function shouldOfferCopy({
|
||||
fieldKey,
|
||||
origin,
|
||||
@@ -130,42 +99,6 @@ function shouldOfferCopy({
|
||||
|
||||
type RowVariant = "compact" | "profile";
|
||||
|
||||
// ── Provenance sentence ──────────────────────────────────────────────────────
|
||||
|
||||
function provenanceSentence(
|
||||
origin: ConfigOrigin,
|
||||
writeVia: ConfigWriteMechanism,
|
||||
configFilePath: string | null,
|
||||
): string | null {
|
||||
switch (origin) {
|
||||
case "buzzExplicit":
|
||||
return "Set in Buzz";
|
||||
case "personaDefault":
|
||||
return null;
|
||||
case "runtimeOverride":
|
||||
return "Live override (this session only)";
|
||||
case "harnessConstraint":
|
||||
return "Locked by harness";
|
||||
case "envVar": {
|
||||
if (writeVia.type === "respawnWithEnvVar") {
|
||||
return `From environment variable (${writeVia.envKey})`;
|
||||
}
|
||||
return "From environment variable";
|
||||
}
|
||||
case "configFile":
|
||||
return configFilePath
|
||||
? `From config file (${configFilePath})`
|
||||
: "From config file";
|
||||
case "acpConfigOption":
|
||||
case "acpNativeRead":
|
||||
return "From ACP session";
|
||||
case "globalDefault":
|
||||
return "Inherited from global defaults";
|
||||
case "harnessDefault":
|
||||
return "Inherited from harness definition";
|
||||
}
|
||||
}
|
||||
|
||||
// ── Normalized row ────────────────────────────────────────────────────────────
|
||||
|
||||
const NORMALIZED_LABELS: Record<keyof NormalizedConfig, string> = {
|
||||
@@ -192,42 +125,29 @@ function NormalizedRow({
|
||||
fieldKey,
|
||||
label,
|
||||
field,
|
||||
isPreSpawn,
|
||||
configFilePath,
|
||||
onEdit,
|
||||
variant = "compact",
|
||||
}: {
|
||||
fieldKey: keyof NormalizedConfig;
|
||||
label: string;
|
||||
field: NormalizedField;
|
||||
isPreSpawn: boolean;
|
||||
configFilePath: string | null;
|
||||
onEdit?: () => void;
|
||||
variant?: RowVariant;
|
||||
}) {
|
||||
const Icon = NORMALIZED_ICONS[fieldKey];
|
||||
// ACP-sourced origins only become meaningful post-spawn
|
||||
const isAcpOnly =
|
||||
field.origin === "acpNativeRead" || field.origin === "acpConfigOption";
|
||||
const rawDisplayValue =
|
||||
isPreSpawn && isAcpOnly
|
||||
? "Available after agent starts"
|
||||
: (field.value ?? "—");
|
||||
const rawDisplayValue = field.value ?? "—";
|
||||
const displayValue =
|
||||
fieldKey === "provider"
|
||||
? providerDisplayLabel(rawDisplayValue)
|
||||
: rawDisplayValue;
|
||||
const provenance = field.value
|
||||
? provenanceSentence(field.origin, field.writeVia, configFilePath)
|
||||
: null;
|
||||
const locked = isReadOnlyField(field);
|
||||
const isEditable = variant === "profile" && onEdit !== undefined;
|
||||
|
||||
const content = (
|
||||
<>
|
||||
{variant === "compact" ? (
|
||||
<Icon className="h-4 w-4 shrink-0 text-muted-foreground" />
|
||||
) : null}
|
||||
<Icon
|
||||
className="h-4 w-4 shrink-0 text-muted-foreground"
|
||||
data-slot="agent-config-field-icon"
|
||||
/>
|
||||
<span className="min-w-0 flex-1 text-left">
|
||||
{variant === "profile" ? (
|
||||
<ConfigFieldLabel label={label} />
|
||||
@@ -241,25 +161,7 @@ function NormalizedRow({
|
||||
title={field.value ?? undefined}
|
||||
>
|
||||
{displayValue}
|
||||
{!(isPreSpawn && isAcpOnly) && field.overriddenValue ? (
|
||||
<span
|
||||
className={cn(
|
||||
"ml-2 text-xs text-muted-foreground/60",
|
||||
field.origin !== "runtimeOverride" && "line-through",
|
||||
)}
|
||||
title={field.overriddenValue ?? undefined}
|
||||
>
|
||||
{field.overriddenValue}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
{provenance ? (
|
||||
<ProvenanceHint
|
||||
locked={locked}
|
||||
provenance={provenance}
|
||||
showLockIcon={variant === "compact"}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
{isEditable ? (
|
||||
<Pencil
|
||||
@@ -300,17 +202,11 @@ function NormalizedRow({
|
||||
|
||||
function AdvancedRow({
|
||||
field,
|
||||
configFilePath,
|
||||
variant = "compact",
|
||||
}: {
|
||||
field: ConfigField;
|
||||
configFilePath: string | null;
|
||||
variant?: RowVariant;
|
||||
}) {
|
||||
const provenance = field.value
|
||||
? provenanceSentence(field.origin, field.writeVia, configFilePath)
|
||||
: null;
|
||||
const locked = isReadOnlyField(field);
|
||||
const { copied, copy } = useCopyFeedback({
|
||||
label: field.label,
|
||||
value: field.value ?? "",
|
||||
@@ -328,11 +224,6 @@ function AdvancedRow({
|
||||
<span className="font-sans text-muted-foreground">—</span>
|
||||
)}
|
||||
</div>
|
||||
{provenance ? (
|
||||
<div className="mt-0.5 text-2xs text-muted-foreground/70">
|
||||
{provenance}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -351,13 +242,6 @@ function AdvancedRow({
|
||||
>
|
||||
{field.value ?? "—"}
|
||||
</span>
|
||||
{provenance ? (
|
||||
<ProvenanceHint
|
||||
locked={locked}
|
||||
provenance={provenance}
|
||||
showLockIcon={false}
|
||||
/>
|
||||
) : null}
|
||||
</span>
|
||||
{isCopyable ? (
|
||||
<HoverCopyIndicator
|
||||
@@ -405,6 +289,30 @@ function ProfileConfigSection({
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* #3493: caveat shown when the surface was read from a user-set
|
||||
* `CLAUDE_CONFIG_DIR`. Claude Code keys its stored login to the config-dir
|
||||
* path, so a custom dir maps to a fresh Keychain namespace — the agent starts
|
||||
* logged out unless `CLAUDE_SECURESTORAGE_CONFIG_DIR` is set to match the
|
||||
* default login.
|
||||
*/
|
||||
function ClaudeConfigDirNotice() {
|
||||
return (
|
||||
<div className="mt-3 border-t border-border/50 px-4 pt-2">
|
||||
<p className="text-xs text-muted-foreground/80">
|
||||
⚠ Custom <code className="font-mono text-xs">CLAUDE_CONFIG_DIR</code>{" "}
|
||||
active — config is read from that directory. Claude Code keys its login
|
||||
to the config-dir path, so a custom dir creates a new Keychain
|
||||
namespace. The agent will need to re-authenticate unless you also set{" "}
|
||||
<code className="font-mono text-xs">
|
||||
CLAUDE_SECURESTORAGE_CONFIG_DIR
|
||||
</code>{" "}
|
||||
to match your default login.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function AgentConfigPanel({
|
||||
advancedMode = "collapsed",
|
||||
onEdit,
|
||||
@@ -471,9 +379,9 @@ export function AgentConfigSurfaceRows({
|
||||
}: AgentConfigSurfaceRowsProps) {
|
||||
const [advancedOpen, setAdvancedOpen] = React.useState(false);
|
||||
|
||||
const { normalized, advanced, extensions, runtimeId, sources, isPreSpawn } =
|
||||
data;
|
||||
const configFilePath = sources.configFilePath;
|
||||
const { normalized, advanced, extensions, runtimeId, sources } = data;
|
||||
const mcpConfigFilePath = sources.mcpConfigFilePath;
|
||||
const claudeConfigDirCustom = data.claudeConfigDirCustom ?? false;
|
||||
|
||||
const normalizedEntries = (
|
||||
Object.entries(normalized) as [
|
||||
@@ -510,18 +418,11 @@ export function AgentConfigSurfaceRows({
|
||||
testId="user-profile-model-settings-section"
|
||||
title="Model settings"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"divide-y divide-border/55",
|
||||
isPreSpawn && "opacity-60",
|
||||
)}
|
||||
>
|
||||
<div className="divide-y divide-border/55">
|
||||
{normalizedEntries.map(([key, field]) => (
|
||||
<NormalizedRow
|
||||
configFilePath={configFilePath}
|
||||
field={field}
|
||||
fieldKey={key}
|
||||
isPreSpawn={isPreSpawn}
|
||||
key={key}
|
||||
label={NORMALIZED_LABELS[key]}
|
||||
onEdit={onEdit}
|
||||
@@ -539,6 +440,7 @@ export function AgentConfigSurfaceRows({
|
||||
>
|
||||
<McpServersSection
|
||||
extensions={extensions}
|
||||
mcpConfigFilePath={mcpConfigFilePath}
|
||||
runtimeId={runtimeId}
|
||||
variant="profile"
|
||||
/>
|
||||
@@ -551,15 +453,12 @@ export function AgentConfigSurfaceRows({
|
||||
title="Advanced"
|
||||
>
|
||||
{advanced.map((field) => (
|
||||
<AdvancedRow
|
||||
configFilePath={configFilePath}
|
||||
field={field}
|
||||
key={field.key}
|
||||
variant="profile"
|
||||
/>
|
||||
<AdvancedRow field={field} key={field.key} variant="profile" />
|
||||
))}
|
||||
</ProfileConfigSection>
|
||||
) : null}
|
||||
|
||||
{claudeConfigDirCustom ? <ClaudeConfigDirNotice /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -567,9 +466,7 @@ export function AgentConfigSurfaceRows({
|
||||
return (
|
||||
<div className="space-y-0.5">
|
||||
{/* Normalized section */}
|
||||
<div
|
||||
className={cn("divide-y divide-border/50", isPreSpawn && "opacity-60")}
|
||||
>
|
||||
<div className="divide-y divide-border/50">
|
||||
{normalizedEntries.length === 0 ? (
|
||||
<p className="py-2 text-xs text-muted-foreground">
|
||||
No config fields available.
|
||||
@@ -577,12 +474,10 @@ export function AgentConfigSurfaceRows({
|
||||
) : (
|
||||
normalizedEntries.map(([key, field]) => (
|
||||
<NormalizedRow
|
||||
key={key}
|
||||
fieldKey={key}
|
||||
label={NORMALIZED_LABELS[key]}
|
||||
field={field}
|
||||
isPreSpawn={isPreSpawn}
|
||||
configFilePath={configFilePath}
|
||||
key={key}
|
||||
variant="compact"
|
||||
/>
|
||||
))
|
||||
@@ -591,6 +486,7 @@ export function AgentConfigSurfaceRows({
|
||||
|
||||
<McpServersSection
|
||||
extensions={extensions}
|
||||
mcpConfigFilePath={mcpConfigFilePath}
|
||||
runtimeId={runtimeId}
|
||||
variant="compact"
|
||||
/>
|
||||
@@ -613,16 +509,14 @@ export function AgentConfigSurfaceRows({
|
||||
{advancedOpen ? (
|
||||
<div className="mt-1 divide-y divide-border/50">
|
||||
{advanced.map((field) => (
|
||||
<AdvancedRow
|
||||
key={field.key}
|
||||
field={field}
|
||||
configFilePath={configFilePath}
|
||||
/>
|
||||
<AdvancedRow field={field} key={field.key} />
|
||||
))}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{claudeConfigDirCustom ? <ClaudeConfigDirNotice /> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,30 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFile } from "node:fs/promises";
|
||||
import test from "node:test";
|
||||
|
||||
const source = await readFile(
|
||||
new URL("./AgentConfigPanel.tsx", import.meta.url),
|
||||
"utf8",
|
||||
);
|
||||
|
||||
test("shared configuration rows show only the effective value", () => {
|
||||
for (const forbiddenPattern of [
|
||||
/Available after agent starts/,
|
||||
/provenanceSentence/,
|
||||
/ProvenanceHint/,
|
||||
/field\.overriddenValue/,
|
||||
/line-through/,
|
||||
/isPreSpawn\s*&&\s*["']opacity-/,
|
||||
]) {
|
||||
assert.doesNotMatch(source, forbiddenPattern);
|
||||
}
|
||||
});
|
||||
|
||||
test("unknown normalized values use an em dash", () => {
|
||||
assert.match(source, /const rawDisplayValue = field\.value \?\? "—";/);
|
||||
});
|
||||
|
||||
test("profile model rows keep their bare leading icons", () => {
|
||||
assert.match(source, /data-slot="agent-config-field-icon"/);
|
||||
assert.doesNotMatch(source, /rounded-full bg-muted[^\n]*<Icon/);
|
||||
});
|
||||
@@ -484,12 +484,6 @@ export function AgentDefinitionDialog({
|
||||
const modelFieldVisible =
|
||||
runtime.trim().length > 0 || blankRuntimeModelProviderEditable;
|
||||
const isExplicitModelRequired = aiConfigurationMode === "custom";
|
||||
// Gate the provider requirement on the field's actual visibility, not the raw
|
||||
// runtime capability. Codex/Claude hide the provider picker (they drive their
|
||||
// own provider), so Customize must not require a provider there. But a
|
||||
// runtime-less legacy/builtin definition still exposes the picker via
|
||||
// blankRuntimeModelProviderEditable, so it must keep requiring a provider —
|
||||
// otherwise Save could persist `provider: undefined` despite the visible field.
|
||||
const customAiPairSatisfied = agentAiConfigurationModeSatisfied(
|
||||
aiConfigurationMode,
|
||||
{ provider, model },
|
||||
@@ -739,7 +733,6 @@ export function AgentDefinitionDialog({
|
||||
isPending={isPending}
|
||||
onCancel={() => handleOpenChange(false)}
|
||||
publishesCatalogUpdates={publishCatalogUpdatesOnSave && hasUserChanges}
|
||||
submitBlockReason={null}
|
||||
submitLabel={submitLabel}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -6,7 +6,6 @@ type AgentDefinitionDialogFooterProps = {
|
||||
isPending: boolean;
|
||||
onCancel: () => void;
|
||||
publishesCatalogUpdates: boolean;
|
||||
submitBlockReason: string | null;
|
||||
submitLabel: string;
|
||||
};
|
||||
|
||||
@@ -16,20 +15,11 @@ export function AgentDefinitionDialogFooter({
|
||||
isPending,
|
||||
onCancel,
|
||||
publishesCatalogUpdates,
|
||||
submitBlockReason,
|
||||
submitLabel,
|
||||
}: AgentDefinitionDialogFooterProps) {
|
||||
return (
|
||||
<div className="flex w-full flex-wrap items-center justify-between gap-3">
|
||||
<div className="flex min-h-9 min-w-0 flex-wrap items-center gap-3">
|
||||
{submitBlockReason ? (
|
||||
<p
|
||||
className="text-2xs text-muted-foreground"
|
||||
data-testid="persona-dialog-submit-reason"
|
||||
>
|
||||
{submitBlockReason}
|
||||
</p>
|
||||
) : null}
|
||||
{publishesCatalogUpdates ? (
|
||||
<p
|
||||
className="max-w-sm text-xs text-muted-foreground"
|
||||
|
||||
@@ -23,6 +23,7 @@ import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
|
||||
import { Dialog } from "@/shared/ui/dialog";
|
||||
import { Input } from "@/shared/ui/input";
|
||||
import { setManagedAgentAutoRestart } from "@/shared/api/tauriManagedAgents";
|
||||
import { EffortPickerField } from "./EffortPickerField";
|
||||
import { EditAgentAdvancedFields } from "./EditAgentAdvancedFields";
|
||||
import {
|
||||
ADVANCED_FIELDS_MOTION_TRANSITION,
|
||||
@@ -255,10 +256,10 @@ export function AgentInstanceEditDialog({
|
||||
return runtimeSupportsLlmProviderSelection(matched?.id ?? "");
|
||||
}, [runtimes, originalAgentCommand]);
|
||||
|
||||
// The runtime id active after submit. Inheriting resolves from the LINKED PERSONA's runtime
|
||||
// (that is what runs once the override is cleared, not the current override).
|
||||
// Falls back to dual-match (command path, then id) when no persona or its runtime is unset.
|
||||
// This single prospective id feeds BOTH the block-save gate and submit so they always agree.
|
||||
// The runtime id active after submit — the single prospective id feeding BOTH
|
||||
// the block-save gate and submit so they always agree. Inheriting resolves
|
||||
// from the LINKED PERSONA's runtime (what runs once the override is cleared),
|
||||
// falling back to dual-match (command path, then id) when no persona.
|
||||
const prospectiveRuntimeId = React.useMemo(() => {
|
||||
if (!inheritHarness) {
|
||||
return selectedRuntime?.id ?? selectedRuntimeId;
|
||||
@@ -413,11 +414,10 @@ export function AgentInstanceEditDialog({
|
||||
selectedRuntime,
|
||||
});
|
||||
|
||||
// D2: derive advancedRequiredEnvKeys for EnvVarsEditor display.
|
||||
// The full requiredEnvKeys/requiredEnvKeyMissing continue driving Save gating.
|
||||
// D2/D3: the top-level API key owns display, while the readiness gate keeps
|
||||
// the complete required-key list. The effective snapshot covers persona
|
||||
// inheritance during an instance inherit transition.
|
||||
// D2/D3: the top-level API key owns display while the readiness gate keeps the
|
||||
// complete required-key list; advancedRequiredEnvKeys drives EnvVarsEditor
|
||||
// display only. The effective snapshot covers persona inheritance during an
|
||||
// instance inherit transition.
|
||||
const providerApiKeyEnvVar = getProviderApiKeyEnvVar(effectiveProvider);
|
||||
const personaSatisfied =
|
||||
providerApiKeyEnvVar != null &&
|
||||
@@ -681,11 +681,9 @@ export function AgentInstanceEditDialog({
|
||||
: normalizedModel !== (agent.model ?? null)
|
||||
? normalizedModel
|
||||
: undefined,
|
||||
// Tri-state provider persistence keyed on providerRuntimeCapability:
|
||||
// "capable" → persist: value if changed, omit if unchanged.
|
||||
// "locked" → clear: send null if provider was set, else omit.
|
||||
// "unknown" → omit always (never send null for a transient state).
|
||||
// llmProviderFieldVisible is for UX visibility only; not used here.
|
||||
// Tri-state provider persistence keyed on providerRuntimeCapability
|
||||
// (see the classification comment above for the capable/locked/unknown
|
||||
// contract). llmProviderFieldVisible is UX visibility only; not used here.
|
||||
provider:
|
||||
linkedPersona != null
|
||||
? undefined
|
||||
@@ -1122,6 +1120,8 @@ export function AgentInstanceEditDialog({
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<EffortPickerField agent={agent} config={configSurfaceQuery.data} />
|
||||
|
||||
<AgentAiDefaultsNotice
|
||||
onEditDefaults={() => setAiDefaultsOpen(true)}
|
||||
triggerRef={aiDefaultsTriggerRef}
|
||||
|
||||
81
desktop/src/features/agents/ui/EffortPickerField.tsx
Normal file
81
desktop/src/features/agents/ui/EffortPickerField.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query";
|
||||
|
||||
import { agentConfigSurfaceQueryKey } from "@/features/agents/hooks";
|
||||
import { persistAgentEffortLevel } from "@/shared/api/tauriManagedAgents";
|
||||
import type { ManagedAgent, RuntimeConfigSurface } from "@/shared/api/types";
|
||||
import { PERSONA_LABEL_OPTIONAL_CLASS } from "./agentConfigOptions";
|
||||
import {
|
||||
effortPickerState,
|
||||
effortSelectionToPersistedValue,
|
||||
} from "./effortPicker";
|
||||
import { PersonaDropdownField } from "./PersonaDropdownField";
|
||||
|
||||
/**
|
||||
* Thinking-effort write control for the edit dialog (B5, v4 direct-write).
|
||||
*
|
||||
* Local-only by construction: the write calls `persistAgentEffortLevel`, which
|
||||
* the Rust command rejects for non-local backends (remote effort is set at
|
||||
* deploy time via `policy_env`). So the control renders only for a local
|
||||
* backend AND once the adapter has advertised a `thought_level` configId
|
||||
* (discovered from the running session — absent pre-first-session and for
|
||||
* runtimes/models without effort support). The read-only configured-vs-running
|
||||
* two-facts display lives in `AgentConfigPanel`; this is the write control.
|
||||
*
|
||||
* Direct-write: each selection persists immediately and invalidates the config
|
||||
* surface so the panel's canonical tier reflects the new next-spawn value.
|
||||
*/
|
||||
export function EffortPickerField({
|
||||
agent,
|
||||
config,
|
||||
}: {
|
||||
agent: ManagedAgent;
|
||||
config: RuntimeConfigSurface | undefined;
|
||||
}) {
|
||||
const queryClient = useQueryClient();
|
||||
const mutation = useMutation({
|
||||
mutationFn: (level: string | null) =>
|
||||
persistAgentEffortLevel(agent.pubkey, level),
|
||||
onSuccess: () =>
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: agentConfigSurfaceQueryKey(agent.pubkey),
|
||||
}),
|
||||
});
|
||||
const { visible, options, selectValue } = effortPickerState({
|
||||
backend: agent.backend,
|
||||
effortConfigId: config?.effortConfigId,
|
||||
effortOptions: config?.effortOptions,
|
||||
currentEffort: config?.normalized.thinkingEffort?.value ?? null,
|
||||
});
|
||||
|
||||
if (!visible) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-1.5">
|
||||
<label
|
||||
className="text-sm font-medium text-foreground"
|
||||
htmlFor="edit-agent-effort"
|
||||
>
|
||||
Thinking effort
|
||||
<span className={PERSONA_LABEL_OPTIONAL_CLASS}>Optional</span>
|
||||
</label>
|
||||
<PersonaDropdownField
|
||||
disabled={mutation.isPending}
|
||||
id="edit-agent-effort"
|
||||
onValueChange={(value) =>
|
||||
mutation.mutate(effortSelectionToPersistedValue(value))
|
||||
}
|
||||
options={options}
|
||||
placeholder="Adapter default"
|
||||
value={selectValue}
|
||||
/>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
Applied at the next session start.
|
||||
</p>
|
||||
{mutation.error instanceof Error ? (
|
||||
<p className="text-xs text-destructive">{mutation.error.message}</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -22,6 +22,7 @@ import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastE
|
||||
import { ManagedAgentLogPanel } from "./ManagedAgentLogPanel";
|
||||
import { PubKey } from "@/shared/ui/PubKey";
|
||||
import { SubsectionLabel } from "@/shared/ui/PageHeader";
|
||||
import { resolveModelLabel } from "@/features/agents/lib/formatAgentModelLabel";
|
||||
import { RestartDiffBadge } from "./RestartDiffBadge";
|
||||
|
||||
export function ManagedAgentRow({
|
||||
@@ -410,7 +411,9 @@ function RuntimeBlock({
|
||||
{runtimeSource || agent.model ? (
|
||||
<div className="flex flex-wrap items-center gap-x-3 gap-y-1 text-xs text-muted-foreground">
|
||||
{runtimeSource ? <span>{runtimeSource}</span> : null}
|
||||
{agent.model ? <span>{agent.model}</span> : null}
|
||||
{agent.model ? (
|
||||
<span>{resolveModelLabel(agent.model, null, agent.provider)}</span>
|
||||
) : null}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
90
desktop/src/features/agents/ui/McpServersSection.test.mjs
Normal file
90
desktop/src/features/agents/ui/McpServersSection.test.mjs
Normal file
@@ -0,0 +1,90 @@
|
||||
/**
|
||||
* #3493 provenance: the MCP servers section must attribute its entries to the
|
||||
* ACTUAL config file the reader read — which, under a custom CLAUDE_CONFIG_DIR,
|
||||
* is the isolated `<custom>/.claude.json`, not the default `~/.claude.json`.
|
||||
*
|
||||
* Before this fix `mcpConfigFilePath` was carried on the DTO but no component
|
||||
* consumed it, so the panel listed the correct servers with no file
|
||||
* attribution at all. These pin the rendered contract.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { after, afterEach, before, test } from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
import {
|
||||
McpServersSection,
|
||||
mcpConfigFileCaption,
|
||||
} from "./McpServersSection.tsx";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
before(() => {
|
||||
Object.assign(globalThis, {
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
window: dom.window,
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
const { cleanup } = await import("@testing-library/react");
|
||||
cleanup();
|
||||
});
|
||||
|
||||
after(() => dom.window.close());
|
||||
|
||||
const CLAUDE_MCP = [{ name: "sentinel", kind: "stdio", enabled: true }];
|
||||
|
||||
test("mcpConfigFileCaption_customPath_returnsFileAttribution", () => {
|
||||
assert.equal(
|
||||
mcpConfigFileCaption("/tmp/iso/.claude.json"),
|
||||
"From config file (/tmp/iso/.claude.json)",
|
||||
);
|
||||
});
|
||||
|
||||
test("mcpConfigFileCaption_nullPath_returnsNull", () => {
|
||||
assert.equal(mcpConfigFileCaption(null), null);
|
||||
assert.equal(mcpConfigFileCaption(undefined), null);
|
||||
});
|
||||
|
||||
test("McpServersSection_customConfigDir_rendersIsolatedFilePath", async () => {
|
||||
const { render } = await import("@testing-library/react");
|
||||
const React = await import("react");
|
||||
|
||||
const { container } = render(
|
||||
React.createElement(McpServersSection, {
|
||||
extensions: CLAUDE_MCP,
|
||||
mcpConfigFilePath: "/tmp/iso/.claude.json",
|
||||
runtimeId: "claude",
|
||||
variant: "compact",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(container.textContent, /sentinel/);
|
||||
assert.match(
|
||||
container.textContent,
|
||||
/From config file \(\/tmp\/iso\/\.claude\.json\)/,
|
||||
);
|
||||
});
|
||||
|
||||
test("McpServersSection_noConfigPath_omitsFileAttribution", async () => {
|
||||
const { render } = await import("@testing-library/react");
|
||||
const React = await import("react");
|
||||
|
||||
const { container } = render(
|
||||
React.createElement(McpServersSection, {
|
||||
extensions: CLAUDE_MCP,
|
||||
mcpConfigFilePath: null,
|
||||
runtimeId: "claude",
|
||||
variant: "compact",
|
||||
}),
|
||||
);
|
||||
|
||||
assert.match(container.textContent, /sentinel/);
|
||||
assert.doesNotMatch(container.textContent, /From config file/);
|
||||
});
|
||||
@@ -5,6 +5,7 @@ import { cn } from "@/shared/lib/cn";
|
||||
type McpServersSectionProps = {
|
||||
extensions: ExtensionEntry[];
|
||||
runtimeId: string | null;
|
||||
mcpConfigFilePath?: string | null;
|
||||
variant?: "compact" | "profile";
|
||||
buzzAgentSlot?: React.ReactNode;
|
||||
};
|
||||
@@ -19,9 +20,19 @@ export function shouldRenderMcpServers(
|
||||
return runtimeId === "buzz-agent" || extensions.length > 0;
|
||||
}
|
||||
|
||||
// #3493: the servers are read from the isolated `.claude.json` under a custom
|
||||
// `CLAUDE_CONFIG_DIR`. Attribute them to that actual file so the panel never
|
||||
// implies the default `~/.claude.json` when isolation is in effect.
|
||||
export function mcpConfigFileCaption(
|
||||
mcpConfigFilePath: string | null | undefined,
|
||||
): string | null {
|
||||
return mcpConfigFilePath ? `From config file (${mcpConfigFilePath})` : null;
|
||||
}
|
||||
|
||||
export function McpServersSection({
|
||||
buzzAgentSlot,
|
||||
extensions,
|
||||
mcpConfigFilePath,
|
||||
runtimeId,
|
||||
variant = "compact",
|
||||
}: McpServersSectionProps) {
|
||||
@@ -31,6 +42,8 @@ export function McpServersSection({
|
||||
return null;
|
||||
}
|
||||
|
||||
const fileCaption = mcpConfigFileCaption(mcpConfigFilePath);
|
||||
|
||||
return (
|
||||
<div
|
||||
className={cn(
|
||||
@@ -67,6 +80,17 @@ export function McpServersSection({
|
||||
No custom servers configured.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{extensions.length > 0 && fileCaption ? (
|
||||
<p
|
||||
className={cn(
|
||||
"truncate text-xs text-muted-foreground/70",
|
||||
variant === "compact" ? "py-1" : "px-4 py-2",
|
||||
)}
|
||||
>
|
||||
{fileCaption}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -23,6 +23,7 @@ import {
|
||||
DropdownMenuRadioItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/shared/ui/dropdown-menu";
|
||||
import { resolveModelLabel } from "@/features/agents/lib/formatAgentModelLabel";
|
||||
|
||||
export function ModelPicker({
|
||||
agent,
|
||||
@@ -82,13 +83,13 @@ export function ModelPicker({
|
||||
);
|
||||
|
||||
const currentValue = agent.model ?? modelsData?.agentDefaultModel ?? "";
|
||||
const displayLabel =
|
||||
agent.model ??
|
||||
(modelsData?.agentDefaultModel
|
||||
? `${modelsData.agentDefaultModel} (default)`
|
||||
const displayLabel = agent.model
|
||||
? resolveModelLabel(agent.model, null, agent.provider)
|
||||
: modelsData?.agentDefaultModel
|
||||
? `${resolveModelLabel(modelsData.agentDefaultModel, null, agent.provider)} (default)`
|
||||
: hasRequestedModels && loading
|
||||
? "Loading..."
|
||||
: "Auto");
|
||||
: "Auto";
|
||||
|
||||
// Provenance label shown only for post-spawn agents where the model origin
|
||||
// is known from the config surface and the source is not a user-explicit
|
||||
@@ -108,26 +109,39 @@ export function ModelPicker({
|
||||
}, [configSurface]);
|
||||
|
||||
// Send a live `switch_model` frame to each channel the agent is working in
|
||||
// and wait for the harness to acknowledge. Any single `unsupported_model`
|
||||
// result rejects the whole pick immediately; all other statuses must arrive
|
||||
// from every channel before resolving success.
|
||||
// and wait for the harness to acknowledge. A single `unsupported_model`
|
||||
// (model unavailable) or `failure` (adapter refused) result rejects the whole
|
||||
// pick immediately. The busy-path `sent` ack is provisional (the adapter
|
||||
// isn't consulted until the requeued session); success is confirmed only by a
|
||||
// real positive terminal frame from every channel, and if none arrives before
|
||||
// the timeout the pick resolves `"pending"` (accepted, apply deferred).
|
||||
const sendLiveSwitch = React.useCallback(
|
||||
(modelId: string) => {
|
||||
const channelIds = activeTurns.map((turn) => turn.channelId);
|
||||
// Opaque per-pick correlator. The harness echoes it on the immediate ack
|
||||
// and the late terminal frame, so a five-minute reconnect replay of an
|
||||
// earlier pick's result cannot settle this one.
|
||||
const requestId = crypto.randomUUID();
|
||||
return awaitLiveSwitchOutcome({
|
||||
channelCount: channelIds.length,
|
||||
modelId,
|
||||
requestId,
|
||||
channelIds,
|
||||
subscribe: (listener) =>
|
||||
subscribeControlResults(agent.pubkey, listener),
|
||||
sendSwitches: async () => {
|
||||
await Promise.all(
|
||||
channelIds.map((channelId) =>
|
||||
switchManagedAgentModel(agent.pubkey, channelId, modelId),
|
||||
switchManagedAgentModel(
|
||||
agent.pubkey,
|
||||
channelId,
|
||||
modelId,
|
||||
requestId,
|
||||
),
|
||||
),
|
||||
);
|
||||
},
|
||||
// No reply in time: treat as sent. The override still rides the
|
||||
// requeued/next session; we just can't confirm synchronously.
|
||||
// No positive terminal in time: resolve `"pending"`. The override still
|
||||
// rides the requeued/next session; we just can't confirm synchronously,
|
||||
// and must not claim a success that hasn't happened.
|
||||
scheduleTimeout: (onTimeout) => {
|
||||
const timeout = window.setTimeout(onTimeout, 8_000);
|
||||
return () => window.clearTimeout(timeout);
|
||||
@@ -147,6 +161,31 @@ export function ModelPicker({
|
||||
toast.error("That model isn't available for this agent.");
|
||||
return;
|
||||
}
|
||||
if (outcome === "failed") {
|
||||
toast.error(
|
||||
"Couldn't switch models — the agent kept its current model.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (outcome === "not_delivered") {
|
||||
// The switch never reached a session: the turn was already ending, or
|
||||
// no active turn remained by the time the harness received it. Nothing
|
||||
// was applied and nothing rides a later session — tell the truth.
|
||||
toast.error(
|
||||
"Couldn't switch models — the agent wasn't running a turn to switch.",
|
||||
);
|
||||
return;
|
||||
}
|
||||
if (outcome === "pending") {
|
||||
// The switch was accepted but its apply is deferred to the next
|
||||
// session (the agent is mid-turn) and didn't confirm before the
|
||||
// fallback timeout. Tell the truth instead of claiming success.
|
||||
toast.info(
|
||||
"Model switch pending — applies when the current turn finishes.",
|
||||
);
|
||||
onModelChanged?.();
|
||||
return;
|
||||
}
|
||||
toast.success("Model switched for this session.");
|
||||
onModelChanged?.();
|
||||
return;
|
||||
@@ -221,7 +260,9 @@ export function ModelPicker({
|
||||
<div className="px-3 py-2 text-sm text-muted-foreground">
|
||||
{agent.model ? (
|
||||
<>
|
||||
<p className="font-medium text-foreground">{agent.model}</p>
|
||||
<p className="font-medium text-foreground">
|
||||
{resolveModelLabel(agent.model, null, agent.provider)}
|
||||
</p>
|
||||
<p className="mt-0.5 text-xs">
|
||||
This runtime does not support switching models.
|
||||
</p>
|
||||
@@ -237,7 +278,7 @@ export function ModelPicker({
|
||||
>
|
||||
{modelsData.models.map((model) => (
|
||||
<DropdownMenuRadioItem key={model.id} value={model.id}>
|
||||
{model.name ?? model.id}
|
||||
{resolveModelLabel(model.id, model.name, agent.provider)}
|
||||
</DropdownMenuRadioItem>
|
||||
))}
|
||||
</DropdownMenuRadioGroup>
|
||||
|
||||
25
desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx
Normal file
25
desktop/src/features/agents/ui/OtherSetupAgentMarker.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
import { Cloud } from "lucide-react";
|
||||
|
||||
import { cn } from "@/shared/lib/cn";
|
||||
|
||||
const OTHER_SETUP_LABEL = "From another Buzz setup";
|
||||
|
||||
export function OtherSetupAgentMarker({
|
||||
className,
|
||||
testId,
|
||||
}: {
|
||||
className?: string;
|
||||
testId?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
aria-label={OTHER_SETUP_LABEL}
|
||||
className={cn("inline-flex shrink-0", className)}
|
||||
data-testid={testId}
|
||||
role="img"
|
||||
title={OTHER_SETUP_LABEL}
|
||||
>
|
||||
<Cloud aria-hidden="true" className="h-3 w-3" />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,6 @@ import {
|
||||
import { Button } from "@/shared/ui/button";
|
||||
import { Dialog } from "@/shared/ui/dialog";
|
||||
import { ChooserDialogContent } from "@/shared/ui/chooser-dialog-content";
|
||||
import { Markdown } from "@/shared/ui/markdown";
|
||||
import { Skeleton } from "@/shared/ui/skeleton";
|
||||
|
||||
import { AgentDefinitionMetadata } from "./AgentDefinitionMetadata";
|
||||
@@ -49,17 +48,6 @@ type PersonaCatalogDialogProps = {
|
||||
type PendingNavigation =
|
||||
| { type: "close" }
|
||||
| { type: "selection"; selection: string };
|
||||
|
||||
const agentInstructionMarkdownClassName = [
|
||||
"mt-3 w-full min-w-0 max-w-full overflow-x-hidden leading-6 text-muted-foreground [&>*]:min-w-0 [&>*]:max-w-full [&_.code-block-lines]:min-w-0 [&_.code-block-lines]:max-w-full [&_.code-block-lines]:whitespace-pre-wrap [&_.code-block-lines]:[overflow-wrap:anywhere] [&_.inline-code-chip]:max-w-full [&_.inline-code-chip]:whitespace-pre-wrap [&_.inline-code-chip]:[overflow-wrap:anywhere] [&_blockquote]:!text-muted-foreground [&_code]:!text-muted-foreground [&_li]:text-muted-foreground [&_ol]:text-muted-foreground [&_p]:text-muted-foreground [&_strong]:text-muted-foreground [&_td]:text-muted-foreground [&_ul]:text-muted-foreground",
|
||||
"[&>h1]:!text-sm [&>h1]:!font-semibold [&>h1]:!leading-6 [&>h1]:!tracking-normal [&>h1]:!text-foreground",
|
||||
"[&>h2]:!text-sm [&>h2]:!font-semibold [&>h2]:!leading-6 [&>h2]:!tracking-normal [&>h2]:!text-foreground",
|
||||
"[&>h3]:!text-sm [&>h3]:!font-semibold [&>h3]:!leading-6 [&>h3]:!tracking-normal [&>h3]:!text-foreground",
|
||||
"[&>h4]:!text-sm [&>h4]:!font-semibold [&>h4]:!leading-6 [&>h4]:!tracking-normal [&>h4]:!text-foreground",
|
||||
"[&>h5]:!text-sm [&>h5]:!font-semibold [&>h5]:!leading-6 [&>h5]:!tracking-normal [&>h5]:!text-foreground",
|
||||
"[&>h6]:!text-sm [&>h6]:!font-semibold [&>h6]:!leading-6 [&>h6]:!tracking-normal [&>h6]:!text-foreground",
|
||||
].join(" ");
|
||||
|
||||
export function PersonaCatalogDialog({
|
||||
createContent,
|
||||
error,
|
||||
@@ -536,6 +524,28 @@ export function resolveCatalogOwnerLabel(
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* Security review surface for instructions that will execute verbatim.
|
||||
*
|
||||
* Do not replace this with the chat Markdown renderer: Markdown intentionally
|
||||
* hides spoiler bodies, link destinations, and image sources, so the reviewed
|
||||
* text would differ from the system prompt sent to the agent.
|
||||
*/
|
||||
export function AgentInstructionReview({
|
||||
instructions,
|
||||
}: {
|
||||
instructions: string;
|
||||
}) {
|
||||
return (
|
||||
<pre
|
||||
className="mt-3 w-full min-w-0 max-w-full whitespace-pre-wrap break-words font-sans text-sm leading-6 text-muted-foreground"
|
||||
data-testid="persona-catalog-exact-instructions"
|
||||
>
|
||||
{instructions || "No instructions included."}
|
||||
</pre>
|
||||
);
|
||||
}
|
||||
|
||||
function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) {
|
||||
const isCommunityEntry =
|
||||
isCatalogPersona(persona) && !persona.catalogSource.isOwn;
|
||||
@@ -582,13 +592,9 @@ function PersonaCatalogDetail({ persona }: { persona: AgentPersona }) {
|
||||
|
||||
<div className="min-w-0 max-w-full pt-3">
|
||||
<p className="text-base font-semibold text-foreground">
|
||||
Agent instruction
|
||||
Agent instructions
|
||||
</p>
|
||||
<Markdown
|
||||
className={agentInstructionMarkdownClassName}
|
||||
content={persona.systemPrompt}
|
||||
interactive={false}
|
||||
/>
|
||||
<AgentInstructionReview instructions={persona.systemPrompt} />
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
||||
@@ -391,7 +391,10 @@ export function SnapshotShareDialog({
|
||||
toast.success(`Sent a copy of ${displayName}`);
|
||||
onOpenChange(false);
|
||||
} else if (sent === false) {
|
||||
toast.error(`Couldn’t send ${itemLabel}. Try again.`);
|
||||
toast.error(
|
||||
snapshotSendController.getCurrentError() ??
|
||||
`Couldn’t send ${itemLabel}. Try again.`,
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -88,8 +88,8 @@ function ChangeDescription({ change }: { change: RestartChange }) {
|
||||
const TOOLTIP_CAP = 6;
|
||||
|
||||
/**
|
||||
* `tooltip` — renders inside the dark `bg-primary` tooltip; uses
|
||||
* `text-primary-foreground` variants for contrast there.
|
||||
* `tooltip` — renders inside the semantic secondary tooltip surface; uses
|
||||
* `text-secondary-foreground` variants for contrast there.
|
||||
* `inline` — renders inside the amber Runtime banner or other light
|
||||
* surfaces; inherits foreground from the container instead.
|
||||
*/
|
||||
@@ -107,10 +107,10 @@ function DiffList({
|
||||
cap !== undefined && entries.length > cap ? entries.length - cap : 0;
|
||||
|
||||
const valueClass =
|
||||
variant === "tooltip" ? "text-primary-foreground/80" : "text-foreground";
|
||||
variant === "tooltip" ? "text-secondary-foreground/80" : "text-foreground";
|
||||
const overflowClass =
|
||||
variant === "tooltip"
|
||||
? "text-primary-foreground/60"
|
||||
? "text-secondary-foreground/60"
|
||||
: "text-muted-foreground";
|
||||
|
||||
return (
|
||||
@@ -180,7 +180,7 @@ export function RestartDiffBadge({
|
||||
<TooltipContent className="max-w-72 text-xs" side="bottom">
|
||||
<p className="mb-1.5 font-semibold">Config changed since last start:</p>
|
||||
<DiffList cap={TOOLTIP_CAP} entries={restartDiff} />
|
||||
<p className="mt-1.5 text-primary-foreground/70">
|
||||
<p className="mt-1.5 text-secondary-foreground/70">
|
||||
{autoRestartEnabled ? AUTO_RESTART_ON_BLURB : AUTO_RESTART_OFF_BLURB}
|
||||
</p>
|
||||
</TooltipContent>
|
||||
|
||||
@@ -203,7 +203,7 @@ function TeamAvatarItem({
|
||||
|
||||
function getTeamFooterModelLabel(personas: AgentPersona[]) {
|
||||
const modelLabels = personas
|
||||
.map((persona) => formatAgentModelLabel(persona.model))
|
||||
.map((persona) => formatAgentModelLabel(persona.model, persona.provider))
|
||||
.filter((model): model is string => Boolean(model));
|
||||
|
||||
if (modelLabels.length === 0) return "Auto";
|
||||
|
||||
@@ -8,6 +8,8 @@ import {
|
||||
import { resolveAgentCardModelLabel } from "@/features/agents/lib/agentCardModelLabel";
|
||||
import { friendlyAgentLastError } from "@/features/agents/lib/friendlyAgentLastError";
|
||||
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
|
||||
import { pickProfileAgent } from "@/features/agents/lib/pickProfileAgent";
|
||||
import { useIsArchivedPredicate } from "@/features/identity-archive/hooks";
|
||||
import { useUserProfileQuery } from "@/features/profile/hooks";
|
||||
import type { AgentPersona, ManagedAgent } from "@/shared/api/types";
|
||||
import type { ProfilePanelOpenOptions } from "@/shared/context/ProfilePanelContext";
|
||||
@@ -18,7 +20,7 @@ import { AgentIdentityCard } from "./AgentIdentityCard";
|
||||
import { AgentRuntimeAvatarControl } from "./AgentRuntimeAvatarControl";
|
||||
import { CreateIdentityCard } from "./CreateIdentityCard";
|
||||
import { PersonaActionsMenu } from "./PersonaActionsMenu";
|
||||
import { buildUnifiedGroups, pickProfileAgent } from "./unifiedAgentGroups";
|
||||
import { buildUnifiedGroups } from "./unifiedAgentGroups";
|
||||
|
||||
type UnifiedAgentsSectionProps = {
|
||||
defaultModel: string;
|
||||
@@ -93,9 +95,10 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
onDeletePersona,
|
||||
} = props;
|
||||
|
||||
const isArchived = useIsArchivedPredicate();
|
||||
const { groups, ungrouped, unknown } = React.useMemo(
|
||||
() => buildUnifiedGroups(personas, agents),
|
||||
[personas, agents],
|
||||
() => buildUnifiedGroups(personas, agents, isArchived),
|
||||
[personas, agents, isArchived],
|
||||
);
|
||||
const [collapsed, setCollapsed] = React.useState<Set<string>>(new Set());
|
||||
function toggle(key: string) {
|
||||
@@ -128,7 +131,7 @@ export function UnifiedAgentsSection(props: UnifiedAgentsSectionProps) {
|
||||
onClick={onOpenCatalog}
|
||||
/>
|
||||
{groups.map((group) => {
|
||||
const profileAgent = pickProfileAgent(group.agents);
|
||||
const profileAgent = pickProfileAgent(group.agents, isArchived);
|
||||
return (
|
||||
<AgentPersonaCard
|
||||
actions={(effectiveAvatarUrl, isEffectiveAvatarLoading) => (
|
||||
@@ -253,6 +256,7 @@ function AgentPersonaCard({
|
||||
const modelLabel = resolveAgentCardModelLabel({
|
||||
agent,
|
||||
personaModel: persona.model,
|
||||
provider: persona.provider,
|
||||
defaultModel,
|
||||
});
|
||||
const isActive = agent ? isManagedAgentActive(agent) : false;
|
||||
@@ -263,7 +267,6 @@ function AgentPersonaCard({
|
||||
const friendlyError = agent
|
||||
? friendlyAgentLastError(agent.lastError, agent.lastErrorCode)?.copy
|
||||
: null;
|
||||
const opensRuntimeTab = Boolean(agent && friendlyError && !isActive);
|
||||
|
||||
return (
|
||||
<AgentIdentityCard
|
||||
@@ -311,13 +314,15 @@ function AgentPersonaCard({
|
||||
label={title}
|
||||
modelLabel={modelLabel}
|
||||
onClick={() => {
|
||||
if (agent) {
|
||||
onOpenAgentProfile(
|
||||
agent.pubkey,
|
||||
opensRuntimeTab ? { tab: "runtime" } : undefined,
|
||||
);
|
||||
return;
|
||||
}
|
||||
// The card's main click always opens the PERSONA target, never an
|
||||
// explicit pubkey. A pubkey target is durable in the panel, so a pick
|
||||
// made during the archive-snapshot fail-open window would strand the
|
||||
// panel on an archived identity after hydration (Carl's cold-hydration
|
||||
// race). A persona target re-resolves every render through the shared
|
||||
// archive-aware selector, so it self-corrects to a live sibling — or
|
||||
// persona-only mode when every instance is archived. Deliberate
|
||||
// instance navigation and the runtime-error affordance keep their
|
||||
// explicit-pubkey path via the avatar control below.
|
||||
onOpenPersonaProfile(persona);
|
||||
}}
|
||||
statusBadge={
|
||||
@@ -392,6 +397,7 @@ function StandaloneAgentCard({
|
||||
modelLabel={resolveAgentCardModelLabel({
|
||||
agent,
|
||||
personaModel: null,
|
||||
provider: agent.provider,
|
||||
defaultModel,
|
||||
})}
|
||||
onClick={() => {
|
||||
|
||||
@@ -0,0 +1,295 @@
|
||||
/**
|
||||
* Rule 1 regression: the persona card's MAIN click records a PERSONA target,
|
||||
* never an explicit pubkey — even during the archive-snapshot fail-open window,
|
||||
* when pickProfileAgent transiently selects an archived sibling.
|
||||
*
|
||||
* Why a mounted render test rather than a pure resolver test:
|
||||
* resolveCanonicalManagedAgent (unit-tested separately) proves a persona
|
||||
* target self-corrects to the live sibling after hydration — but it assumes
|
||||
* the card emits a persona target. The defect being closed is the card
|
||||
* emitting a durable *pubkey* target that survives hydration. Only mounting
|
||||
* the real card and firing its main click catches a mutation that reverts
|
||||
* onClick back to onOpenAgentProfile(agent.pubkey). AgentPersonaCard is
|
||||
* module-local, so the whole section is mounted.
|
||||
*
|
||||
* Fail-open is reproduced faithfully: the list_archived_identities IPC call
|
||||
* never settles, so useIsArchivedPredicate returns all-live at click time and
|
||||
* pickProfileAgent selects the archived-first sibling — exactly the transient
|
||||
* window the durable pubkey target used to strand the panel on.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { after, afterEach, before, test } from "node:test";
|
||||
|
||||
import { JSDOM } from "jsdom";
|
||||
|
||||
const dom = new JSDOM("<!doctype html><html><body></body></html>", {
|
||||
url: "http://localhost",
|
||||
});
|
||||
|
||||
// Track every client so afterEach can drop cached queries. A query left pending
|
||||
// (the fail-open archive snapshot) plus react-query's default gcTime schedules
|
||||
// timers that outlive the test and stall the shared `pnpm test` process.
|
||||
const clients = [];
|
||||
|
||||
let act;
|
||||
let cleanup;
|
||||
let fireEvent;
|
||||
let render;
|
||||
let screen;
|
||||
let createElement;
|
||||
let QueryClient;
|
||||
let QueryClientProvider;
|
||||
let UnifiedAgentsSection;
|
||||
|
||||
const ipcHandlers = new Map();
|
||||
|
||||
const SELF_PK = "c".repeat(64);
|
||||
const ARCHIVED_PK = "a".repeat(64);
|
||||
const LIVE_PK = "b".repeat(64);
|
||||
|
||||
function agent(overrides = {}) {
|
||||
return {
|
||||
pubkey: LIVE_PK,
|
||||
name: "Instance",
|
||||
personaId: "persona-1",
|
||||
status: "stopped",
|
||||
model: null,
|
||||
modelSource: "global",
|
||||
lastError: null,
|
||||
lastErrorCode: null,
|
||||
needsRestart: false,
|
||||
personaOrphaned: false,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function persona(overrides = {}) {
|
||||
return {
|
||||
id: "persona-1",
|
||||
displayName: "Fizz Prime",
|
||||
avatarUrl: null,
|
||||
model: null,
|
||||
isBuiltIn: false,
|
||||
sourceTeam: null,
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function baseProps(overrides = {}) {
|
||||
return {
|
||||
defaultModel: "gpt-x",
|
||||
actionErrorMessage: null,
|
||||
actionNoticeMessage: null,
|
||||
agents: [],
|
||||
agentsError: null,
|
||||
isActionPending: false,
|
||||
isAgentsLoading: false,
|
||||
restartingAgentPubkey: null,
|
||||
startingAgentPubkey: null,
|
||||
startingPersonaIds: new Set(),
|
||||
onOpenAgentProfile: () => {},
|
||||
onOpenPersonaProfile: () => {},
|
||||
onRestartAgent: () => {},
|
||||
onStartAgent: () => {},
|
||||
onStartPersona: () => {},
|
||||
personas: [],
|
||||
personasError: null,
|
||||
personaFeedbackErrorMessage: null,
|
||||
personaFeedbackNoticeMessage: null,
|
||||
isPersonasLoading: false,
|
||||
isPersonasPending: false,
|
||||
onOpenCatalog: () => {},
|
||||
onDuplicatePersona: () => {},
|
||||
onEditPersona: () => {},
|
||||
onSharePersona: () => {},
|
||||
onDeactivatePersona: () => {},
|
||||
onDeletePersona: () => {},
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function renderSection(props) {
|
||||
const client = new QueryClient({
|
||||
defaultOptions: { queries: { retry: false, gcTime: 0 } },
|
||||
});
|
||||
clients.push(client);
|
||||
return render(
|
||||
createElement(
|
||||
QueryClientProvider,
|
||||
{ client },
|
||||
createElement(UnifiedAgentsSection, props),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
before(async () => {
|
||||
Object.assign(globalThis, {
|
||||
document: dom.window.document,
|
||||
HTMLElement: dom.window.HTMLElement,
|
||||
window: dom.window,
|
||||
IS_REACT_ACT_ENVIRONMENT: true,
|
||||
});
|
||||
Object.defineProperty(globalThis, "navigator", {
|
||||
configurable: true,
|
||||
value: dom.window.navigator,
|
||||
writable: true,
|
||||
});
|
||||
dom.window.matchMedia = () => ({
|
||||
matches: true,
|
||||
addEventListener() {},
|
||||
removeEventListener() {},
|
||||
});
|
||||
dom.window.__TAURI_INTERNALS__ = {
|
||||
invoke: (cmd, args) => {
|
||||
const handler = ipcHandlers.get(cmd);
|
||||
if (handler) return handler(args);
|
||||
return Promise.reject(new Error(`unmocked Tauri command: ${cmd}`));
|
||||
},
|
||||
transformCallback: () => Math.random(),
|
||||
};
|
||||
|
||||
({ act, cleanup, fireEvent, render, screen } = await import(
|
||||
"@testing-library/react"
|
||||
));
|
||||
({ createElement } = await import("react"));
|
||||
({ QueryClient, QueryClientProvider } = await import(
|
||||
"@tanstack/react-query"
|
||||
));
|
||||
({ UnifiedAgentsSection } = await import("./UnifiedAgentsSection.tsx"));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
cleanup?.();
|
||||
for (const client of clients.splice(0)) {
|
||||
client.cancelQueries();
|
||||
client.clear();
|
||||
}
|
||||
ipcHandlers.clear();
|
||||
});
|
||||
|
||||
after(() => dom.window.close());
|
||||
|
||||
function installFailOpenIpc() {
|
||||
ipcHandlers.set("get_identity", () =>
|
||||
Promise.resolve({ pubkey: SELF_PK, display_name: "Me" }),
|
||||
);
|
||||
// Never resolves: the archive snapshot stays loading, so the predicate is
|
||||
// fail-open (treats every identity as live) for the whole test.
|
||||
ipcHandlers.set("list_archived_identities", () => new Promise(() => {}));
|
||||
ipcHandlers.set("get_user_profile", () =>
|
||||
Promise.resolve({
|
||||
pubkey: LIVE_PK,
|
||||
display_name: null,
|
||||
avatar_url: null,
|
||||
about: null,
|
||||
nip05_handle: null,
|
||||
owner_pubkey: null,
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
test("persona card main click records a persona target, never an explicit pubkey", async () => {
|
||||
installFailOpenIpc();
|
||||
|
||||
let recordedPersona;
|
||||
const onOpenAgentProfile = () => {
|
||||
throw new Error("card main click must not open an explicit pubkey target");
|
||||
};
|
||||
const onOpenPersonaProfile = (persona) => {
|
||||
recordedPersona = persona;
|
||||
};
|
||||
|
||||
// Archived sibling sorts first by name, so under fail-open pickProfileAgent
|
||||
// selects it — the card displays the archived identity at click time. A
|
||||
// durable pubkey target would strand the panel there after hydration.
|
||||
const agents = [
|
||||
agent({ pubkey: ARCHIVED_PK, name: "Archived Sibling" }),
|
||||
agent({ pubkey: LIVE_PK, name: "Zed Sibling" }),
|
||||
];
|
||||
|
||||
await act(async () => {
|
||||
renderSection(
|
||||
baseProps({
|
||||
agents,
|
||||
personas: [persona()],
|
||||
onOpenAgentProfile,
|
||||
onOpenPersonaProfile,
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Fizz Prime agent profile" }),
|
||||
);
|
||||
|
||||
assert.ok(recordedPersona, "the click must record a persona target");
|
||||
assert.equal(recordedPersona.id, "persona-1");
|
||||
});
|
||||
|
||||
test("persona card main click records a persona target even for a stopped errored agent", async () => {
|
||||
installFailOpenIpc();
|
||||
|
||||
let recordedPersona;
|
||||
await act(async () => {
|
||||
renderSection(
|
||||
baseProps({
|
||||
agents: [
|
||||
agent({
|
||||
pubkey: LIVE_PK,
|
||||
name: "Errored",
|
||||
status: "stopped",
|
||||
lastError: "boom",
|
||||
}),
|
||||
],
|
||||
personas: [persona()],
|
||||
onOpenAgentProfile: () => {
|
||||
throw new Error("main click must not open an explicit pubkey target");
|
||||
},
|
||||
onOpenPersonaProfile: (persona) => {
|
||||
recordedPersona = persona;
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
fireEvent.click(
|
||||
screen.getByRole("button", { name: "Fizz Prime agent profile" }),
|
||||
);
|
||||
|
||||
assert.equal(recordedPersona?.id, "persona-1");
|
||||
});
|
||||
|
||||
test("errored avatar affordance still opens the explicit pubkey on the runtime tab", async () => {
|
||||
installFailOpenIpc();
|
||||
|
||||
const opened = [];
|
||||
await act(async () => {
|
||||
renderSection(
|
||||
baseProps({
|
||||
agents: [
|
||||
agent({
|
||||
pubkey: LIVE_PK,
|
||||
name: "Errored",
|
||||
status: "stopped",
|
||||
lastError: "boom",
|
||||
}),
|
||||
],
|
||||
personas: [persona()],
|
||||
onOpenAgentProfile: (pubkey, options) => {
|
||||
opened.push({ pubkey, options });
|
||||
},
|
||||
onOpenPersonaProfile: () => {
|
||||
throw new Error("the error affordance must open the explicit pubkey");
|
||||
},
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
// The error badge is the deliberate explicit-pubkey path preserved for
|
||||
// manage/diagnose access; it is the reserved instance/error navigation that
|
||||
// rule 1 keeps valid, unchanged by the main-click fix.
|
||||
fireEvent.click(screen.getByTestId(`agent-runtime-error-${LIVE_PK}`));
|
||||
|
||||
assert.deepEqual(opened, [{ pubkey: LIVE_PK, options: { tab: "runtime" } }]);
|
||||
});
|
||||
@@ -4,6 +4,7 @@ import test from "node:test";
|
||||
import {
|
||||
agentAiConfigurationModeSatisfied,
|
||||
agentAiConfigurationPairForMode,
|
||||
agentAiConfigurationSubmitBlockReason,
|
||||
initialAgentAiConfigurationMode,
|
||||
} from "./agentAiConfigurationPolicy.ts";
|
||||
|
||||
@@ -50,6 +51,38 @@ test("Customize requires a complete explicit pair", () => {
|
||||
);
|
||||
});
|
||||
|
||||
test("incomplete Customize explains why Save remains disabled", () => {
|
||||
assert.equal(
|
||||
agentAiConfigurationSubmitBlockReason("custom", {
|
||||
provider: "",
|
||||
model: "",
|
||||
}),
|
||||
"Choose a provider to save custom AI configuration.",
|
||||
);
|
||||
assert.equal(
|
||||
agentAiConfigurationSubmitBlockReason("custom", {
|
||||
provider: "anthropic",
|
||||
model: "",
|
||||
}),
|
||||
"Choose a model to save custom AI configuration.",
|
||||
);
|
||||
assert.equal(
|
||||
agentAiConfigurationSubmitBlockReason(
|
||||
"custom",
|
||||
{ provider: "", model: "" },
|
||||
false,
|
||||
),
|
||||
"Choose a model to save custom AI configuration.",
|
||||
);
|
||||
assert.equal(
|
||||
agentAiConfigurationSubmitBlockReason("defaults", {
|
||||
provider: "",
|
||||
model: "",
|
||||
}),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("Codex/Claude Customize needs only a model, not the hidden provider", () => {
|
||||
// needsProviderSelection=false → the intentionally hidden provider must not
|
||||
// gate Save (the create/edit "Save stays disabled" regression).
|
||||
|
||||
@@ -47,6 +47,21 @@ export function agentAiConfigurationPairForMode({
|
||||
* runtime capability, so the gate never diverges from the visible picker. It
|
||||
* defaults to `true` so existing callers keep the provider+model requirement.
|
||||
*/
|
||||
export function agentAiConfigurationSubmitBlockReason(
|
||||
mode: AgentAiConfigurationMode,
|
||||
pair: AgentAiConfigurationPair,
|
||||
needsProviderSelection = true,
|
||||
): string | null {
|
||||
if (
|
||||
mode !== "custom" ||
|
||||
agentAiConfigurationModeSatisfied(mode, pair, needsProviderSelection)
|
||||
)
|
||||
return null;
|
||||
return needsProviderSelection && !pair.provider.trim()
|
||||
? "Choose a provider to save custom AI configuration."
|
||||
: "Choose a model to save custom AI configuration.";
|
||||
}
|
||||
|
||||
export function agentAiConfigurationModeSatisfied(
|
||||
mode: AgentAiConfigurationMode,
|
||||
pair: AgentAiConfigurationPair,
|
||||
|
||||
@@ -6,6 +6,6 @@ export function showAgentProfileSyncWarning(
|
||||
) {
|
||||
if (!profileSyncError) return;
|
||||
toast.warning(
|
||||
`${agentName} was saved, but relay profile sync failed: ${profileSyncError}. The relay may still show the old name — restart the agent to retry the sync.`,
|
||||
`${agentName} was saved locally, but relay sync failed: ${profileSyncError}. Remote users may still see the previous name or access policy until Buzz retries the sync.`,
|
||||
);
|
||||
}
|
||||
|
||||
@@ -828,7 +828,7 @@ export function processTranscriptEvent(
|
||||
}
|
||||
} else if (event.kind === "acp_write" && method === "session/new") {
|
||||
// The base + persona prompts ride session/new's systemPrompt, framed by
|
||||
// the harness as [Base]/[System]/[Agent Memory — core]/[Channel Canvas].
|
||||
// the harness as [Base]/[Agent Instructions]/[Agent Memory — core]/[Channel Canvas].
|
||||
// claude-agent-acp uses _meta.systemPrompt.append instead; both paths
|
||||
// produce the same standalone card (turnId: null, acpSource "session/new");
|
||||
// the bare field takes precedence when both are present.
|
||||
|
||||
@@ -201,6 +201,45 @@ test("parseSystemPromptSections splits both prompts into Base and System", () =>
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections splits current Base and Agent Instructions framing", () => {
|
||||
const framed =
|
||||
"[Base]\nbase text\n\n[Workspace]\nCurrent working directory: /workspace\n\n[Agent Instructions]\npersona text";
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Base", body: "base text" },
|
||||
{ title: "Workspace", body: "Current working directory: /workspace" },
|
||||
{ title: "Agent Instructions", body: "persona text" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections preserves a Windows workspace path", () => {
|
||||
const framed =
|
||||
"[Base]\nbase text\n\n[Workspace]\nCurrent working directory: C:\\Users\\me\\buzz\n\n[Agent Instructions]\npersona text";
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Base", body: "base text" },
|
||||
{
|
||||
title: "Workspace",
|
||||
body: "Current working directory: C:\\Users\\me\\buzz",
|
||||
},
|
||||
{ title: "Agent Instructions", body: "persona text" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections preserves the former Workspace-before-Base framing", () => {
|
||||
const framed =
|
||||
"[Workspace]\nYour absolute working directory is `/workspace`.\n\n[Base]\nbase text\n\n[System]\npersona text";
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{
|
||||
title: "Workspace",
|
||||
body: "Your absolute working directory is `/workspace`.",
|
||||
},
|
||||
{ title: "Base", body: "base text" },
|
||||
{ title: "System", body: "persona text" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections yields one Base section for a base-only frame", () => {
|
||||
const sections = parseSystemPromptSections("[Base]\nbase text");
|
||||
assert.deepEqual(sections, [{ title: "Base", body: "base text" }]);
|
||||
@@ -211,6 +250,15 @@ test("parseSystemPromptSections yields one System section for a persona-only fra
|
||||
assert.deepEqual(sections, [{ title: "System", body: "persona text" }]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections yields Agent Instructions for a current persona-only frame", () => {
|
||||
const sections = parseSystemPromptSections(
|
||||
"[Agent Instructions]\npersona text",
|
||||
);
|
||||
assert.deepEqual(sections, [
|
||||
{ title: "Agent Instructions", body: "persona text" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections keeps embedded bracket lines literal in bodies", () => {
|
||||
// A persona that itself contains a [Context]-like line must NOT split into a
|
||||
// spurious sub-section — the body is read literally after the first boundary.
|
||||
@@ -323,18 +371,15 @@ test("parseSystemPromptSections keeps exact core header literal when only a sing
|
||||
]);
|
||||
});
|
||||
|
||||
test("parseSystemPromptSections pins the realistic Workspace+Base+System+Core harness shape", () => {
|
||||
// The real Buzz harness emits [Workspace] content before [Base]. The parser
|
||||
// folds [Workspace] into the Base section (existing unchanged behavior);
|
||||
// core is extracted as a distinct "Core Memory" section last.
|
||||
test("parseSystemPromptSections pins the current Base+Workspace+Agent Instructions+Core harness shape", () => {
|
||||
const framed = [
|
||||
"[Workspace]",
|
||||
"You are operating inside the Buzz platform.",
|
||||
"",
|
||||
"[Base]",
|
||||
"You are an assistant.",
|
||||
"",
|
||||
"[System]",
|
||||
"[Workspace]",
|
||||
"Current working directory: /workspace",
|
||||
"",
|
||||
"[Agent Instructions]",
|
||||
"Custom persona instructions.",
|
||||
"",
|
||||
"[Agent Memory — core]",
|
||||
@@ -344,11 +389,9 @@ test("parseSystemPromptSections pins the realistic Workspace+Base+System+Core ha
|
||||
].join("\n");
|
||||
const sections = parseSystemPromptSections(framed);
|
||||
assert.deepEqual(sections, [
|
||||
{
|
||||
title: "Base",
|
||||
body: "[Workspace]\nYou are operating inside the Buzz platform.\n\n[Base]\nYou are an assistant.",
|
||||
},
|
||||
{ title: "System", body: "Custom persona instructions." },
|
||||
{ title: "Base", body: "You are an assistant." },
|
||||
{ title: "Workspace", body: "Current working directory: /workspace" },
|
||||
{ title: "Agent Instructions", body: "Custom persona instructions." },
|
||||
{
|
||||
title: "Core Memory",
|
||||
body: "I am Duncan.\n## Lessons Learned\nAlways tag on handoff.",
|
||||
|
||||
@@ -56,12 +56,12 @@ export function parsePromptText(text: string): {
|
||||
}
|
||||
|
||||
/**
|
||||
* Split the framed `session/new` `systemPrompt` into its `Base`/`System`/
|
||||
* Split the framed `session/new` `systemPrompt` into its `Base`/`Agent Instructions`/
|
||||
* `Team Instructions`/`Core Memory`/`Channel Canvas` sub-sections
|
||||
* deterministically.
|
||||
*
|
||||
* The harness composes the value in order:
|
||||
* `[Base]\n{base}\n\n[System]\n{persona}\n\n[Team Instructions]\n{team}\n\n[Agent Memory — core]\n{core}\n\n[Channel Canvas]\n{canvas}`
|
||||
* `[Base]\n{base}\n\n[Agent Instructions]\n{persona}\n\n[Team Instructions]\n{team}\n\n[Agent Memory — core]\n{core}\n\n[Channel Canvas]\n{canvas}`
|
||||
* with any section omitted when absent. Extraction runs in reverse producer
|
||||
* order so that each `lastIndexOf` search operates on the full input and each
|
||||
* extraction boundary is unambiguous.
|
||||
@@ -80,18 +80,19 @@ export function parsePromptText(text: string): {
|
||||
* 3. **Team Instructions** (`[Team Instructions]`): appended before core by
|
||||
* `with_team()` in `buzz-acp/src/pool.rs`. Same two cases (start-of-string
|
||||
* or `\n\n[Team Instructions]\n` inline), same last-occurrence guard. Output
|
||||
* position: after System, before Core Memory.
|
||||
* position: after Agent Instructions, before Core Memory.
|
||||
*
|
||||
* 4. **Base/System**: remainder after the three top-level section extractions.
|
||||
* Split on the first `\n[System]\n` boundary; no embedded `[...]` line
|
||||
* inside a body can start a new section.
|
||||
* 4. **Base/Agent Instructions**: remainder after the three top-level section
|
||||
* extractions. Split on the first `\n[Agent Instructions]\n` boundary.
|
||||
* Archived frames using the former `[System]` header remain supported and
|
||||
* retain their historical observer label.
|
||||
*
|
||||
* 5. **Legacy Team Instructions** (backward compat): if the `System` body
|
||||
* 5. **Legacy Team Instructions** (backward compat): if the agent-instructions body
|
||||
* contains the exact canonical delimiter `\n\n---\n# Team Instructions\n`
|
||||
* (produced by the now-removed `compose_prompt()` in buzz-persona), the body
|
||||
* is split at the **last** occurrence of that boundary. The text before
|
||||
* becomes the `System` body; the text after becomes a `Team Instructions`
|
||||
* section inserted immediately after `System`. Non-canonical lookalikes
|
||||
* becomes the agent-instructions body; the text after becomes a `Team Instructions`
|
||||
* section inserted immediately after it. Non-canonical lookalikes
|
||||
* (bare `---` without the heading, a `# Team Instructions` on a different
|
||||
* line, or only a single preceding newline) are kept literal inside `System`.
|
||||
*/
|
||||
@@ -137,7 +138,7 @@ export function parseSystemPromptSections(
|
||||
|
||||
// ── 3. Extract [Team Instructions] (modern runtime framing) ─────────────
|
||||
// with_team() in buzz-acp/src/pool.rs appends "\n\n[Team Instructions]\n{instructions}"
|
||||
// after [System] and before core/canvas. Same two cases as canvas/core:
|
||||
// after [Agent Instructions] and before core/canvas. Same two cases as canvas/core:
|
||||
// start-of-string (team-only input) or the inline double-newline marker
|
||||
// (last occurrence guards against embedded lookalikes preceded by a single \n).
|
||||
const TEAM_HEADER = "[Team Instructions]";
|
||||
@@ -157,48 +158,110 @@ export function parseSystemPromptSections(
|
||||
}
|
||||
}
|
||||
|
||||
// ── 4. Parse Base/System from the remaining prefix ────────────────────────
|
||||
// ── 4. Parse Base/Workspace/Agent Instructions from the remaining prefix ─
|
||||
// The canonical team-instructions delimiter produced by compose_prompt() in
|
||||
// buzz-persona/src/resolve.rs:
|
||||
// format!("{persona_prompt}\n\n---\n# Team Instructions\n{instructions}")
|
||||
const TEAM_DELIMITER = "\n\n---\n# Team Instructions\n";
|
||||
|
||||
// splitSystemBody: split a raw [System] body string at the last occurrence
|
||||
// of the canonical team delimiter, returning { systemBody, teamBody | null }.
|
||||
// splitInstructionsBody: split a raw agent-instructions body string at the last occurrence
|
||||
// of the canonical team delimiter, returning { instructionsBody, teamBody | null }.
|
||||
// Using lastIndexOf mirrors the canvas/core last-occurrence guard: a persona
|
||||
// author can embed an exact delimiter-like passage inside the persona body;
|
||||
// only the final occurrence is the producer boundary appended by compose_prompt().
|
||||
function splitSystemBody(raw: string): {
|
||||
systemBody: string;
|
||||
function splitInstructionsBody(raw: string): {
|
||||
instructionsBody: string;
|
||||
teamBody: string | null;
|
||||
} {
|
||||
const at = raw.lastIndexOf(TEAM_DELIMITER);
|
||||
if (at === -1) return { systemBody: raw.trim(), teamBody: null };
|
||||
if (at === -1) return { instructionsBody: raw.trim(), teamBody: null };
|
||||
return {
|
||||
systemBody: raw.slice(0, at).trim(),
|
||||
instructionsBody: raw.slice(0, at).trim(),
|
||||
teamBody: raw.slice(at + TEAM_DELIMITER.length).trim() || null,
|
||||
};
|
||||
}
|
||||
|
||||
const baseAndSystem = remainder;
|
||||
if (baseAndSystem) {
|
||||
if (baseAndSystem.startsWith("[System]\n")) {
|
||||
const raw = baseAndSystem.slice("[System]\n".length);
|
||||
const { systemBody, teamBody } = splitSystemBody(raw);
|
||||
if (systemBody) sections.push({ title: "System", body: systemBody });
|
||||
const instructionFrames = [
|
||||
{ header: "[Agent Instructions]", title: "Agent Instructions" },
|
||||
{ header: "[System]", title: "System" },
|
||||
] as const;
|
||||
|
||||
function appendBaseAndWorkspace(raw: string): void {
|
||||
const BASE_HEADER = "[Base]";
|
||||
const WORKSPACE_HEADER = "[Workspace]";
|
||||
const workspaceMarker = `\n\n${WORKSPACE_HEADER}\n`;
|
||||
const baseMarker = `\n\n${BASE_HEADER}\n`;
|
||||
|
||||
// Current framing keeps the static base first, followed by the dynamic cwd.
|
||||
if (raw.startsWith(`${BASE_HEADER}\n`)) {
|
||||
const workspaceAt = raw.lastIndexOf(workspaceMarker);
|
||||
if (workspaceAt !== -1) {
|
||||
const baseBody = raw
|
||||
.slice(`${BASE_HEADER}\n`.length, workspaceAt)
|
||||
.trim();
|
||||
const workspaceBody = raw
|
||||
.slice(workspaceAt + workspaceMarker.length)
|
||||
.trim();
|
||||
if (baseBody) sections.push({ title: "Base", body: baseBody });
|
||||
if (workspaceBody)
|
||||
sections.push({ title: "Workspace", body: workspaceBody });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
// Preserve readable transcripts for sessions captured with the former
|
||||
// Workspace-before-Base framing.
|
||||
if (raw.startsWith(`${WORKSPACE_HEADER}\n`)) {
|
||||
const baseAt = raw.lastIndexOf(baseMarker);
|
||||
if (baseAt !== -1) {
|
||||
const workspaceBody = raw
|
||||
.slice(`${WORKSPACE_HEADER}\n`.length, baseAt)
|
||||
.trim();
|
||||
const baseBody = raw.slice(baseAt + baseMarker.length).trim();
|
||||
if (workspaceBody)
|
||||
sections.push({ title: "Workspace", body: workspaceBody });
|
||||
if (baseBody) sections.push({ title: "Base", body: baseBody });
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
const baseBody = raw.replace(/^\[Base]\n/, "").trim();
|
||||
if (baseBody) sections.push({ title: "Base", body: baseBody });
|
||||
}
|
||||
|
||||
const baseAndInstructions = remainder;
|
||||
if (baseAndInstructions) {
|
||||
const leadingFrame = instructionFrames.find(({ header }) =>
|
||||
baseAndInstructions.startsWith(`${header}\n`),
|
||||
);
|
||||
if (leadingFrame) {
|
||||
const raw = baseAndInstructions.slice(`${leadingFrame.header}\n`.length);
|
||||
const { instructionsBody, teamBody } = splitInstructionsBody(raw);
|
||||
if (instructionsBody)
|
||||
sections.push({ title: leadingFrame.title, body: instructionsBody });
|
||||
if (teamBody)
|
||||
sections.push({ title: "Team Instructions", body: teamBody });
|
||||
} else {
|
||||
const marker = "\n[System]\n";
|
||||
const at = baseAndSystem.indexOf(marker);
|
||||
const head = at === -1 ? baseAndSystem : baseAndSystem.slice(0, at);
|
||||
const baseBody = head.replace(/^\[Base]\n/, "").trim();
|
||||
if (baseBody) sections.push({ title: "Base", body: baseBody });
|
||||
const boundary = instructionFrames
|
||||
.map((frame) => ({
|
||||
...frame,
|
||||
marker: `\n${frame.header}\n`,
|
||||
at: baseAndInstructions.indexOf(`\n${frame.header}\n`),
|
||||
}))
|
||||
.filter(({ at }) => at !== -1)
|
||||
.sort((a, b) => a.at - b.at)[0];
|
||||
const head = boundary
|
||||
? baseAndInstructions.slice(0, boundary.at)
|
||||
: baseAndInstructions;
|
||||
appendBaseAndWorkspace(head);
|
||||
|
||||
if (at !== -1) {
|
||||
const raw = baseAndSystem.slice(at + marker.length);
|
||||
const { systemBody, teamBody } = splitSystemBody(raw);
|
||||
if (systemBody) sections.push({ title: "System", body: systemBody });
|
||||
if (boundary) {
|
||||
const raw = baseAndInstructions.slice(
|
||||
boundary.at + boundary.marker.length,
|
||||
);
|
||||
const { instructionsBody, teamBody } = splitInstructionsBody(raw);
|
||||
if (instructionsBody)
|
||||
sections.push({ title: boundary.title, body: instructionsBody });
|
||||
if (teamBody)
|
||||
sections.push({ title: "Team Instructions", body: teamBody });
|
||||
}
|
||||
|
||||
@@ -395,14 +395,15 @@ test("openai gpt-5-pro is not matched by gpt-5 base bucket", () => {
|
||||
);
|
||||
});
|
||||
|
||||
// gpt-5.10 must NOT match gpt-5.1 (digit boundary)
|
||||
test("openai gpt-5.10 is not matched by gpt-5.1 token", () => {
|
||||
const { validValues } = getProviderEffortConfig("openai", "gpt-5.10");
|
||||
// gpt-5.10 doesn't match any specific family → falls into unknown table
|
||||
assert.deepEqual(
|
||||
[...validValues],
|
||||
["none", "minimal", "low", "medium", "high", "xhigh"],
|
||||
// gpt-5.10 must NOT match gpt-5.1 (digit boundary), but DOES match the gpt-5
|
||||
// base rule at the dot boundary → base table, not the unknown fallback.
|
||||
test("openai gpt-5.10 rejects gpt-5.1 at the digit boundary and matches the gpt-5 base rule", () => {
|
||||
const { validValues, defaultValue } = getProviderEffortConfig(
|
||||
"openai",
|
||||
"gpt-5.10",
|
||||
);
|
||||
assert.deepEqual([...validValues], ["minimal", "low", "medium", "high"]);
|
||||
assert.equal(defaultValue, "medium");
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -449,8 +450,9 @@ test("databricks_v2 with databricks-gpt-5.1 strips prefix and routes to OpenAI g
|
||||
});
|
||||
|
||||
test("databricks_v2 with concrete non-claude non-gpt model excludes max (MLflow clamps it)", () => {
|
||||
// llama-3 routes through MlflowChatCompletions → normalize_effort_for_openai_route
|
||||
// → max is clamped to xhigh. Show all-except-max so the UI is honest.
|
||||
// llama-3 falls to the databricks_v2 concrete-unknown fallback, whose
|
||||
// OpenaiClampMaxToXhigh normalization policy clamps max→xhigh
|
||||
// (normalize_effort_for_databricks_v2). Show all-except-max so the UI is honest.
|
||||
const { validValues, defaultValue } = getProviderEffortConfig(
|
||||
"databricks_v2",
|
||||
"llama-3",
|
||||
@@ -541,12 +543,17 @@ test("databricks v1 routes like openai unknown (no gpt-5 model)", () => {
|
||||
assert.equal(defaultValue, "medium");
|
||||
});
|
||||
|
||||
test("openai-compat returns all-7 with medium default", () => {
|
||||
test("openai-compat returns all-except-max with medium default", () => {
|
||||
// openai-compat canonicalizes to openai, whose blank/unknown fallback omits
|
||||
// max (the OpenAI wire route clamps max → xhigh, so the UI stays honest).
|
||||
const { validValues, defaultValue } = getProviderEffortConfig(
|
||||
"openai-compat",
|
||||
"",
|
||||
);
|
||||
assert.equal(validValues.length, 7);
|
||||
assert.deepEqual(
|
||||
[...validValues],
|
||||
["none", "minimal", "low", "medium", "high", "xhigh"],
|
||||
);
|
||||
assert.equal(defaultValue, "medium");
|
||||
});
|
||||
|
||||
|
||||
@@ -1,9 +1,19 @@
|
||||
/**
|
||||
* Source-of-truth constants for buzz-agent model-tuning configuration knobs.
|
||||
*
|
||||
* Values must stay in sync with `crates/buzz-agent/src/config.rs`
|
||||
* `parse_thinking_effort` — that function is the authoritative list.
|
||||
* The thinking-effort value list and the provider/model → effort projection are
|
||||
* both derived from the shared capability manifest via the interpreter in
|
||||
* `./modelCapabilities`; this module owns only the buzz-agent env-var keys and
|
||||
* the runtime-id guard. Mirrors the `config.rs` ⇄ `model_capabilities.rs` seam
|
||||
* in `crates/buzz-agent`, where effort resolution is delegated to the manifest.
|
||||
* (The interpreter owns the value list rather than the reverse, because it uses
|
||||
* the values at module-load for zod — the acyclic direction.)
|
||||
*/
|
||||
import {
|
||||
THINKING_EFFORT_VALUES,
|
||||
type ThinkingEffortValue,
|
||||
resolveModelCapabilities,
|
||||
} from "./modelCapabilities";
|
||||
|
||||
/** Env var key for the thinking/effort level sent to the LLM. */
|
||||
export const BUZZ_AGENT_THINKING_EFFORT = "BUZZ_AGENT_THINKING_EFFORT";
|
||||
@@ -19,24 +29,12 @@ export const BUZZ_AGENT_MAX_ROUNDS = "BUZZ_AGENT_MAX_ROUNDS";
|
||||
|
||||
/**
|
||||
* Ordered set of valid thinking-effort values accepted by buzz-agent.
|
||||
* Mirrors `parse_thinking_effort` in `crates/buzz-agent/src/config.rs`.
|
||||
* Re-exported from the manifest interpreter, which owns the canonical list
|
||||
* (mirrors `parse_thinking_effort` in `crates/buzz-agent/src/config.rs`).
|
||||
*/
|
||||
export const BUZZ_AGENT_THINKING_EFFORT_VALUES = [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
] as const;
|
||||
export const BUZZ_AGENT_THINKING_EFFORT_VALUES = THINKING_EFFORT_VALUES;
|
||||
|
||||
export type ThinkingEffortValue =
|
||||
(typeof BUZZ_AGENT_THINKING_EFFORT_VALUES)[number];
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider-aware effort configuration
|
||||
// ---------------------------------------------------------------------------
|
||||
export type { ThinkingEffortValue };
|
||||
|
||||
/**
|
||||
* Describes which thinking-effort values are valid for a given provider+model,
|
||||
@@ -44,12 +42,8 @@ export type ThinkingEffortValue =
|
||||
*
|
||||
* `defaultValue = null` means the provider/model's default is to omit the
|
||||
* thinking configuration entirely (i.e. "Inherit" is the natural default).
|
||||
* This applies to Anthropic manual-budget models where the effort level maps
|
||||
* to a budget_tokens count — there is no "default effort level" in the API.
|
||||
*
|
||||
* Mirrors the model-family tables in `crates/buzz-agent/src/config.rs`
|
||||
* (`openai_efforts_for_model`, `is_manual_budget_model`,
|
||||
* `is_adaptive_thinking_model`, `clamp_adaptive_effort`). Keep in sync.
|
||||
* This applies to Anthropic manual-budget models, whose effort maps to a
|
||||
* budget_tokens count — there is no "default effort level" in the API.
|
||||
*/
|
||||
export type ProviderEffortConfig = {
|
||||
validValues: ReadonlyArray<ThinkingEffortValue>;
|
||||
@@ -57,246 +51,23 @@ export type ProviderEffortConfig = {
|
||||
defaultValue: ThinkingEffortValue | null;
|
||||
};
|
||||
|
||||
const ALL_VALUES = BUZZ_AGENT_THINKING_EFFORT_VALUES;
|
||||
|
||||
/**
|
||||
* Returns the valid thinking-effort values and semantic default for the
|
||||
* given provider and optional model string.
|
||||
* Returns the valid thinking-effort values and semantic default for the given
|
||||
* provider and optional model, projected from the shared capability manifest.
|
||||
*
|
||||
* Model matching mirrors the Rust backend:
|
||||
* - Anthropic: strip any endpoint-naming prefix, then test `is_manual_budget_model`
|
||||
* / `is_adaptive_thinking_model` / `clamp_adaptive_effort` family checks.
|
||||
* - OpenAI: strip any endpoint-naming prefix, then test `openai_efforts_for_model`
|
||||
* family checks (boundary-aware: -pro before -5.x, digit/letter boundary).
|
||||
* - DatabricksV2: strip prefix and route by model family.
|
||||
* - Unknown/empty: all 7 values, default medium.
|
||||
*
|
||||
* Prefix stripping: finds the first occurrence of a known model-family token
|
||||
* (`claude-`, `gpt-`) and drops everything before it. This handles any
|
||||
* endpoint-naming convention (e.g. `databricks-`, `goose-`, `team-x-`) without
|
||||
* maintaining an allowlist of known prefixes. If no family token is found, the
|
||||
* raw model name is used as-is.
|
||||
* A thin projection over `resolveModelCapabilities`: `validValues` is the
|
||||
* resolved `supportedEfforts` axis and `defaultValue` is `defaultEffort`.
|
||||
* Provider canonicalization (alias map) and endpoint-prefix stripping happen
|
||||
* inside the resolver, so callers pass raw provider/model strings.
|
||||
*/
|
||||
export function getProviderEffortConfig(
|
||||
providerId: string,
|
||||
model?: string,
|
||||
): ProviderEffortConfig {
|
||||
const provider = providerId.toLowerCase();
|
||||
// Strip arbitrary endpoint-naming prefix before model-family matching.
|
||||
// Find the first occurrence of a known family token and drop everything before it.
|
||||
// e.g. "goose-claude-fable-5" → "claude-fable-5"
|
||||
// "team-x-gpt-5.5" → "gpt-5.5"
|
||||
// "databricks-claude-3" → "claude-3"
|
||||
// "claude-opus-4-7" → "claude-opus-4-7" (no prefix to strip)
|
||||
const rawModel = (model ?? "").trim().toLowerCase();
|
||||
const FAMILY_TOKENS = ["claude-", "gpt-"] as const;
|
||||
const firstFamilyIdx = Math.min(
|
||||
...FAMILY_TOKENS.map((tok) => {
|
||||
const idx = rawModel.indexOf(tok);
|
||||
return idx === -1 ? Infinity : idx;
|
||||
}),
|
||||
);
|
||||
const m =
|
||||
firstFamilyIdx === Infinity ? rawModel : rawModel.slice(firstFamilyIdx);
|
||||
|
||||
if (provider === "anthropic") {
|
||||
return anthropicConfig(m);
|
||||
}
|
||||
if (provider === "openai") {
|
||||
return openaiConfig(m);
|
||||
}
|
||||
if (provider === "databricks_v2") {
|
||||
// Route by model family: claude* → Anthropic tables, gpt-5* → OpenAI tables.
|
||||
// Non-Claude concrete models (e.g. llama-3) go through MlflowChatCompletions,
|
||||
// which applies normalize_effort_for_openai_route → clamps max to xhigh.
|
||||
// Route them through openaiConfig to exclude max. Only blank/unknown model
|
||||
// uses the all-7 fallback (can't know the route without a concrete model).
|
||||
if (m.startsWith("claude-")) {
|
||||
return anthropicConfig(m);
|
||||
}
|
||||
if (gpt5FamilyModel(m)) {
|
||||
return openaiConfig(m);
|
||||
}
|
||||
if (m.length > 0) {
|
||||
// Concrete non-Claude, non-GPT model → MLflow path clamps max → xhigh.
|
||||
return openaiConfig(m);
|
||||
}
|
||||
// Blank model — route unknown, show all 7.
|
||||
return { validValues: ALL_VALUES, defaultValue: "medium" };
|
||||
}
|
||||
if (provider === "databricks") {
|
||||
// databricks v1 uses OpenAI Chat Completions wire format.
|
||||
return openaiConfig(m);
|
||||
}
|
||||
if (provider === "openrouter") {
|
||||
return { validValues: ALL_VALUES, defaultValue: "medium" };
|
||||
}
|
||||
// openai-compat, unknown, empty — all values, default medium.
|
||||
return { validValues: ALL_VALUES, defaultValue: "medium" };
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Anthropic family tables
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function anthropicConfig(m: string): ProviderEffortConfig {
|
||||
// Manual-budget models: claude-3* and claude-opus-4-5.
|
||||
// These use budget_tokens — there is no "default effort level" in the API.
|
||||
if (m.startsWith("claude-3") || m === "claude-opus-4-5") {
|
||||
return {
|
||||
validValues: ["low", "medium", "high"],
|
||||
defaultValue: null,
|
||||
};
|
||||
}
|
||||
// Adaptive models that support xhigh: opus-4-7+, sonnet-5.x, fable-5, mythos-5.
|
||||
// mirrors clamp_adaptive_effort supports_xhigh check.
|
||||
if (
|
||||
m.startsWith("claude-opus-4-7") ||
|
||||
m.startsWith("claude-opus-4-8") ||
|
||||
m.startsWith("claude-sonnet-5") ||
|
||||
m.startsWith("claude-fable-5") ||
|
||||
m.startsWith("claude-mythos-5")
|
||||
) {
|
||||
return {
|
||||
validValues: ["low", "medium", "high", "xhigh", "max"],
|
||||
defaultValue: "high",
|
||||
};
|
||||
}
|
||||
// Adaptive models that do NOT support xhigh: opus-4-6, sonnet-4-6, mythos-preview.
|
||||
if (
|
||||
m.startsWith("claude-opus-4-6") ||
|
||||
m.startsWith("claude-sonnet-4-6") ||
|
||||
m.startsWith("claude-mythos-preview")
|
||||
) {
|
||||
return {
|
||||
validValues: ["low", "medium", "high", "max"],
|
||||
defaultValue: "high",
|
||||
};
|
||||
}
|
||||
// Unknown Anthropic model — assume adaptive with full support.
|
||||
const cap = resolveModelCapabilities(providerId, model ?? "");
|
||||
return {
|
||||
validValues: ["low", "medium", "high", "xhigh", "max"],
|
||||
defaultValue: "high",
|
||||
};
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// OpenAI family tables — mirrors openai_efforts_for_model in config.rs
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/**
|
||||
* Returns true if `m` contains a GPT-5 family token at a word boundary
|
||||
* (not immediately followed by a digit or letter). Mirrors
|
||||
* `gpt5_token_matches` / `gpt5_base_matches` in config.rs.
|
||||
*/
|
||||
function gpt5TokenMatches(m: string, token: string): boolean {
|
||||
let start = 0;
|
||||
while (true) {
|
||||
const idx = m.indexOf(token, start);
|
||||
if (idx === -1) return false;
|
||||
const afterIdx = idx + token.length;
|
||||
const afterChar = afterIdx < m.length ? m[afterIdx] : "";
|
||||
// Boundary: end-of-string or a `-` separator (not a digit or letter).
|
||||
if (afterChar === "" || afterChar === "-") return true;
|
||||
start = afterIdx;
|
||||
}
|
||||
}
|
||||
|
||||
/** Like gpt5TokenMatches but also rejects short -<1-3 digit> suffixes (e.g. -5, -10). */
|
||||
function gpt5BaseMatches(m: string, token: string): boolean {
|
||||
let start = 0;
|
||||
while (true) {
|
||||
const idx = m.indexOf(token, start);
|
||||
if (idx === -1) return false;
|
||||
const afterIdx = idx + token.length;
|
||||
const suffix = m.slice(afterIdx);
|
||||
if (suffix === "") return true;
|
||||
if (!suffix.startsWith("-")) {
|
||||
start = afterIdx;
|
||||
continue;
|
||||
}
|
||||
// Has a `-` suffix — check if it looks like a 1-3 digit version number.
|
||||
const dashRest = suffix.slice(1);
|
||||
if (/^\d{1,3}(?:[^a-z\d]|$)/i.test(dashRest)) {
|
||||
start = afterIdx;
|
||||
continue;
|
||||
}
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
/** Returns true if the model string belongs to any GPT-5 family. */
|
||||
function gpt5FamilyModel(m: string): boolean {
|
||||
return (
|
||||
gpt5TokenMatches(m, "gpt-5-pro") ||
|
||||
gpt5TokenMatches(m, "gpt5-pro") ||
|
||||
gpt5TokenMatches(m, "gpt-5.6") ||
|
||||
gpt5TokenMatches(m, "gpt5.6") ||
|
||||
gpt5TokenMatches(m, "gpt-5-6") ||
|
||||
gpt5TokenMatches(m, "gpt5-6") ||
|
||||
gpt5TokenMatches(m, "gpt-5.5") ||
|
||||
gpt5TokenMatches(m, "gpt5.5") ||
|
||||
gpt5TokenMatches(m, "gpt-5.4") ||
|
||||
gpt5TokenMatches(m, "gpt5.4") ||
|
||||
gpt5TokenMatches(m, "gpt-5.1") ||
|
||||
gpt5TokenMatches(m, "gpt5.1") ||
|
||||
gpt5BaseMatches(m, "gpt-5") ||
|
||||
gpt5BaseMatches(m, "gpt5")
|
||||
);
|
||||
}
|
||||
|
||||
function openaiConfig(m: string): ProviderEffortConfig {
|
||||
// Check -pro before versioned suffixes (gpt-5-pro contains "gpt-5").
|
||||
if (gpt5TokenMatches(m, "gpt-5-pro") || gpt5TokenMatches(m, "gpt5-pro")) {
|
||||
return { validValues: ["high"], defaultValue: "high" };
|
||||
}
|
||||
if (
|
||||
gpt5TokenMatches(m, "gpt-5.6") ||
|
||||
gpt5TokenMatches(m, "gpt5.6") ||
|
||||
gpt5TokenMatches(m, "gpt-5-6") ||
|
||||
gpt5TokenMatches(m, "gpt5-6")
|
||||
) {
|
||||
return {
|
||||
validValues: ["none", "low", "medium", "high", "xhigh", "max"],
|
||||
defaultValue: "medium",
|
||||
};
|
||||
}
|
||||
if (
|
||||
gpt5TokenMatches(m, "gpt-5.5") ||
|
||||
gpt5TokenMatches(m, "gpt5.5") ||
|
||||
gpt5TokenMatches(m, "gpt-5-5") ||
|
||||
gpt5TokenMatches(m, "gpt5-5") ||
|
||||
gpt5TokenMatches(m, "gpt-5.4") ||
|
||||
gpt5TokenMatches(m, "gpt5.4") ||
|
||||
gpt5TokenMatches(m, "gpt-5-4") ||
|
||||
gpt5TokenMatches(m, "gpt5-4")
|
||||
) {
|
||||
return {
|
||||
validValues: ["none", "low", "medium", "high", "xhigh"],
|
||||
defaultValue: "medium",
|
||||
};
|
||||
}
|
||||
if (
|
||||
gpt5TokenMatches(m, "gpt-5.1") ||
|
||||
gpt5TokenMatches(m, "gpt5.1") ||
|
||||
gpt5TokenMatches(m, "gpt-5-1") ||
|
||||
gpt5TokenMatches(m, "gpt5-1")
|
||||
) {
|
||||
return {
|
||||
validValues: ["none", "low", "medium", "high"],
|
||||
defaultValue: "none",
|
||||
};
|
||||
}
|
||||
if (gpt5BaseMatches(m, "gpt-5") || gpt5BaseMatches(m, "gpt5")) {
|
||||
return {
|
||||
validValues: ["minimal", "low", "medium", "high"],
|
||||
defaultValue: "medium",
|
||||
};
|
||||
}
|
||||
// Unknown OpenAI model — conservative fallback; max is enabled only for families whose table includes it.
|
||||
return {
|
||||
validValues: ["none", "minimal", "low", "medium", "high", "xhigh"],
|
||||
defaultValue: "medium",
|
||||
validValues: cap.supportedEfforts,
|
||||
defaultValue: cap.defaultEffort,
|
||||
};
|
||||
}
|
||||
|
||||
|
||||
110
desktop/src/features/agents/ui/effortPicker.test.mjs
Normal file
110
desktop/src/features/agents/ui/effortPicker.test.mjs
Normal file
@@ -0,0 +1,110 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
EFFORT_DEFAULT_DROPDOWN_VALUE,
|
||||
effortPickerState,
|
||||
effortSelectionToPersistedValue,
|
||||
} from "./effortPicker.ts";
|
||||
|
||||
const localBackend = { type: "local" };
|
||||
const providerBackend = { type: "provider", id: "openai", config: {} };
|
||||
const options = [
|
||||
{ value: "low", displayName: "Low" },
|
||||
{ value: "high", displayName: "High" },
|
||||
];
|
||||
|
||||
test("effort picker renders for a local backend with a discovered configId", () => {
|
||||
const state = effortPickerState({
|
||||
backend: localBackend,
|
||||
effortConfigId: "thought_level",
|
||||
effortOptions: options,
|
||||
currentEffort: null,
|
||||
});
|
||||
assert.equal(state.visible, true);
|
||||
});
|
||||
|
||||
test("effort picker is hidden for a provider backend even when a configId exists", () => {
|
||||
const state = effortPickerState({
|
||||
backend: providerBackend,
|
||||
effortConfigId: "thought_level",
|
||||
effortOptions: options,
|
||||
currentEffort: "high",
|
||||
});
|
||||
assert.equal(state.visible, false);
|
||||
});
|
||||
|
||||
test("effort picker is hidden for a local backend without a discovered configId", () => {
|
||||
const state = effortPickerState({
|
||||
backend: localBackend,
|
||||
effortConfigId: undefined,
|
||||
effortOptions: undefined,
|
||||
currentEffort: null,
|
||||
});
|
||||
assert.equal(state.visible, false);
|
||||
});
|
||||
|
||||
test("options lead with the adapter-default sentinel then adapter values", () => {
|
||||
const state = effortPickerState({
|
||||
backend: localBackend,
|
||||
effortConfigId: "thought_level",
|
||||
effortOptions: options,
|
||||
currentEffort: null,
|
||||
});
|
||||
assert.deepEqual(state.options, [
|
||||
{ label: "Adapter default", value: EFFORT_DEFAULT_DROPDOWN_VALUE },
|
||||
{ label: "Low", value: "low" },
|
||||
{ label: "High", value: "high" },
|
||||
]);
|
||||
});
|
||||
|
||||
test("option label falls back to the raw value when displayName is absent", () => {
|
||||
const state = effortPickerState({
|
||||
backend: localBackend,
|
||||
effortConfigId: "thought_level",
|
||||
effortOptions: [{ value: "medium" }],
|
||||
currentEffort: null,
|
||||
});
|
||||
assert.deepEqual(state.options[1], { label: "medium", value: "medium" });
|
||||
});
|
||||
|
||||
test("current effort preselects the matching option", () => {
|
||||
const state = effortPickerState({
|
||||
backend: localBackend,
|
||||
effortConfigId: "thought_level",
|
||||
effortOptions: options,
|
||||
currentEffort: "high",
|
||||
});
|
||||
assert.equal(state.selectValue, "high");
|
||||
});
|
||||
|
||||
test("an unknown current effort falls back to the adapter-default sentinel", () => {
|
||||
const state = effortPickerState({
|
||||
backend: localBackend,
|
||||
effortConfigId: "thought_level",
|
||||
effortOptions: options,
|
||||
currentEffort: "extreme",
|
||||
});
|
||||
assert.equal(state.selectValue, EFFORT_DEFAULT_DROPDOWN_VALUE);
|
||||
});
|
||||
|
||||
test("a null current effort selects the adapter-default sentinel", () => {
|
||||
const state = effortPickerState({
|
||||
backend: localBackend,
|
||||
effortConfigId: "thought_level",
|
||||
effortOptions: options,
|
||||
currentEffort: null,
|
||||
});
|
||||
assert.equal(state.selectValue, EFFORT_DEFAULT_DROPDOWN_VALUE);
|
||||
});
|
||||
|
||||
test("the sentinel selection persists as null (clear to adapter default)", () => {
|
||||
assert.equal(
|
||||
effortSelectionToPersistedValue(EFFORT_DEFAULT_DROPDOWN_VALUE),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("a concrete selection persists as its explicit effort level", () => {
|
||||
assert.equal(effortSelectionToPersistedValue("high"), "high");
|
||||
});
|
||||
71
desktop/src/features/agents/ui/effortPicker.ts
Normal file
71
desktop/src/features/agents/ui/effortPicker.ts
Normal file
@@ -0,0 +1,71 @@
|
||||
import type {
|
||||
AcpConfigOptionValue,
|
||||
ManagedAgentBackend,
|
||||
} from "@/shared/api/types";
|
||||
import type { PersonaDropdownOption } from "./agentConfigOptions";
|
||||
|
||||
/**
|
||||
* Sentinel dropdown value for "no explicit effort" — reverts the agent to the
|
||||
* adapter default at the next spawn. Distinct from any adapter option value.
|
||||
*/
|
||||
export const EFFORT_DEFAULT_DROPDOWN_VALUE = "__effort_default__";
|
||||
|
||||
/**
|
||||
* Pure gating + option compute for the effort write control in the edit dialog.
|
||||
*
|
||||
* The picker is a LOCAL-only, direct-write control: it calls
|
||||
* `persistAgentEffortLevel`, which the Rust command rejects for non-local
|
||||
* backends (remote effort is set at deploy time via `policy_env`). So the UI
|
||||
* must not offer it for a provider backend, and there's nothing to pick until
|
||||
* the adapter has advertised a `thought_level` config option (discovered from
|
||||
* the running session — `effortConfigId` is absent pre-first-session and for
|
||||
* runtimes/models that don't support effort).
|
||||
*
|
||||
* `visible` is the single gate the dialog renders on: local backend AND a
|
||||
* discovered `effortConfigId`.
|
||||
*/
|
||||
export function effortPickerState({
|
||||
backend,
|
||||
effortConfigId,
|
||||
effortOptions,
|
||||
currentEffort,
|
||||
}: {
|
||||
backend: ManagedAgentBackend;
|
||||
effortConfigId: string | undefined;
|
||||
effortOptions: readonly AcpConfigOptionValue[] | undefined;
|
||||
currentEffort: string | null;
|
||||
}): {
|
||||
visible: boolean;
|
||||
options: PersonaDropdownOption[];
|
||||
selectValue: string;
|
||||
} {
|
||||
const visible = backend.type === "local" && effortConfigId !== undefined;
|
||||
|
||||
const options: PersonaDropdownOption[] = [
|
||||
{ label: "Adapter default", value: EFFORT_DEFAULT_DROPDOWN_VALUE },
|
||||
...(effortOptions ?? []).map((option) => ({
|
||||
label: option.displayName ?? option.value,
|
||||
value: option.value,
|
||||
})),
|
||||
];
|
||||
|
||||
// Preselect the currently-configured effort when it maps to a known option;
|
||||
// otherwise fall back to the adapter-default sentinel (also the null case).
|
||||
const trimmed = currentEffort?.trim() ?? "";
|
||||
const selectValue =
|
||||
trimmed.length > 0 &&
|
||||
(effortOptions ?? []).some((option) => option.value === trimmed)
|
||||
? trimmed
|
||||
: EFFORT_DEFAULT_DROPDOWN_VALUE;
|
||||
|
||||
return { visible, options, selectValue };
|
||||
}
|
||||
|
||||
/**
|
||||
* Map a dropdown selection back to the value persisted via
|
||||
* `persistAgentEffortLevel`: the sentinel clears effort (null → adapter
|
||||
* default), any other value is the explicit effort level.
|
||||
*/
|
||||
export function effortSelectionToPersistedValue(value: string): string | null {
|
||||
return value === EFFORT_DEFAULT_DROPDOWN_VALUE ? null : value;
|
||||
}
|
||||
@@ -1,254 +0,0 @@
|
||||
[
|
||||
{
|
||||
"note": "Anthropic manual-budget: claude-3 family",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-3-7-sonnet-20250219",
|
||||
"validValues": ["low", "medium", "high"],
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"note": "Anthropic manual-budget: claude-opus-4-5",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-4-5",
|
||||
"validValues": ["low", "medium", "high"],
|
||||
"defaultValue": null
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive xhigh-capable: claude-opus-4-7",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-4-7",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive xhigh-capable: claude-opus-4-8",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-4-8",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive xhigh-capable: claude-sonnet-5",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-5-20260101",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive xhigh-capable: claude-fable-5",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-fable-5",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive xhigh-capable: claude-opus-5",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-5",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive xhigh-capable: claude-mythos-5",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-mythos-5",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive no-xhigh: claude-opus-4-6",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-opus-4-6",
|
||||
"validValues": ["low", "medium", "high", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive no-xhigh: claude-sonnet-4-6",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-sonnet-4-6",
|
||||
"validValues": ["low", "medium", "high", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic adaptive no-xhigh: claude-mythos-preview",
|
||||
"provider": "anthropic",
|
||||
"model": "claude-mythos-preview",
|
||||
"validValues": ["low", "medium", "high", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "Anthropic unknown model: blank \u2014 assume full adaptive",
|
||||
"provider": "anthropic",
|
||||
"model": "",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "OpenAI gpt-5-pro: high only",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5-pro",
|
||||
"validValues": ["high"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "OpenAI gpt-5.6: none/low/medium/high/xhigh/max",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.6",
|
||||
"validValues": ["none", "low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "OpenAI gpt-5.5: none/low/medium/high/xhigh",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.5",
|
||||
"validValues": ["none", "low", "medium", "high", "xhigh"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "OpenAI gpt-5.4: same table as gpt-5.5",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.4",
|
||||
"validValues": ["none", "low", "medium", "high", "xhigh"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "OpenAI gpt-5.1: none/low/medium/high",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5.1",
|
||||
"validValues": ["none", "low", "medium", "high"],
|
||||
"defaultValue": "none"
|
||||
},
|
||||
{
|
||||
"note": "OpenAI gpt-5 base: minimal/low/medium/high",
|
||||
"provider": "openai",
|
||||
"model": "gpt-5",
|
||||
"validValues": ["minimal", "low", "medium", "high"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "OpenAI unknown model (gpt-4o): all-except-max",
|
||||
"provider": "openai",
|
||||
"model": "gpt-4o",
|
||||
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "OpenAI empty model: all-except-max",
|
||||
"provider": "openai",
|
||||
"model": "",
|
||||
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "DatabricksV2 claude route (claude-opus-4-7): xhigh-capable anthropic table",
|
||||
"provider": "databricks_v2",
|
||||
"model": "claude-opus-4-7",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "DatabricksV2 claude route with databricks- prefix stripped",
|
||||
"provider": "databricks_v2",
|
||||
"model": "databricks-claude-opus-4-7",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "DatabricksV2 gpt-5.6-sol route: OpenAI max-capable table",
|
||||
"provider": "databricks_v2",
|
||||
"model": "gpt-5.6-sol",
|
||||
"validValues": ["none", "low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "DatabricksV2 gpt-5-6-sol route: dashed OpenAI max-capable table",
|
||||
"provider": "databricks_v2",
|
||||
"model": "gpt-5-6-sol",
|
||||
"validValues": ["none", "low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "DatabricksV2 gpt-5.4 route: OpenAI gpt-5.5/5.4 table",
|
||||
"provider": "databricks_v2",
|
||||
"model": "gpt-5.4",
|
||||
"validValues": ["none", "low", "medium", "high", "xhigh"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "DatabricksV2 gpt-5.1 with databricks- prefix: OpenAI gpt-5.1 table",
|
||||
"provider": "databricks_v2",
|
||||
"model": "databricks-gpt-5.1",
|
||||
"validValues": ["none", "low", "medium", "high"],
|
||||
"defaultValue": "none"
|
||||
},
|
||||
{
|
||||
"note": "DatabricksV2 concrete non-claude non-gpt5 (llama-3): MLflow path, all-except-max",
|
||||
"provider": "databricks_v2",
|
||||
"model": "llama-3",
|
||||
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "DatabricksV2 blank model: route unknown, all-7",
|
||||
"provider": "databricks_v2",
|
||||
"model": "",
|
||||
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "databricks v1: routes like openai unknown, all-except-max",
|
||||
"provider": "databricks",
|
||||
"model": "",
|
||||
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "openai-compat: all-7 with medium default",
|
||||
"provider": "openai-compat",
|
||||
"model": "",
|
||||
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "openrouter: all-7 with medium default",
|
||||
"provider": "openrouter",
|
||||
"model": "",
|
||||
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "empty provider: all-7 with medium default",
|
||||
"provider": "",
|
||||
"model": "",
|
||||
"validValues": ["none", "minimal", "low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "databricks_v2 goose-claude-fable-5: strips goose- prefix, routes anthropic adaptive+xhigh, max valid",
|
||||
"provider": "databricks_v2",
|
||||
"model": "goose-claude-fable-5",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "databricks_v2 goose-gpt-5.5: strips goose- prefix, routes openai gpt-5.5 table (none+low-xhigh, no minimal)",
|
||||
"provider": "databricks_v2",
|
||||
"model": "goose-gpt-5.5",
|
||||
"validValues": ["none", "low", "medium", "high", "xhigh"],
|
||||
"defaultValue": "medium"
|
||||
},
|
||||
{
|
||||
"note": "databricks_v2 goose-claude-sonnet-5: strips goose- prefix, routes anthropic adaptive+xhigh",
|
||||
"provider": "databricks_v2",
|
||||
"model": "goose-claude-sonnet-5",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
},
|
||||
{
|
||||
"note": "databricks_v2 arbitrary prefix team-x-claude-opus-4-7: strips to claude-opus-4-7, routes anthropic adaptive+xhigh, max valid",
|
||||
"provider": "databricks_v2",
|
||||
"model": "team-x-claude-opus-4-7",
|
||||
"validValues": ["low", "medium", "high", "xhigh", "max"],
|
||||
"defaultValue": "high"
|
||||
}
|
||||
]
|
||||
@@ -1,52 +0,0 @@
|
||||
/**
|
||||
* Effort-table sync guard: TS side.
|
||||
*
|
||||
* Loads the checked-in fixture and asserts that `getProviderEffortConfig`
|
||||
* matches every entry. Drift between `buzzAgentConfig.ts` and the fixture
|
||||
* (e.g. a new model family added to one side but not the other) fails CI.
|
||||
* The companion Rust test in `crates/buzz-agent/src/config.rs` mirrors
|
||||
* this check so both sides of the mirror must stay in sync.
|
||||
*/
|
||||
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import test from "node:test";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import path from "node:path";
|
||||
|
||||
import { getProviderEffortConfig } from "./buzzAgentConfig.ts";
|
||||
|
||||
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
||||
const fixture = JSON.parse(
|
||||
readFileSync(path.join(__dirname, "effortTable.fixture.json"), "utf8"),
|
||||
);
|
||||
|
||||
for (const entry of fixture) {
|
||||
const {
|
||||
note,
|
||||
provider,
|
||||
model,
|
||||
validValues: expectedValidValues,
|
||||
defaultValue: expectedDefault,
|
||||
} = entry;
|
||||
const label = note ?? `${provider}/${model}`;
|
||||
|
||||
test(`effort fixture: ${label}`, () => {
|
||||
const { validValues, defaultValue } = getProviderEffortConfig(
|
||||
provider,
|
||||
model,
|
||||
);
|
||||
|
||||
assert.deepEqual(
|
||||
[...validValues],
|
||||
expectedValidValues,
|
||||
`validValues mismatch for "${label}"`,
|
||||
);
|
||||
|
||||
assert.equal(
|
||||
defaultValue,
|
||||
expectedDefault,
|
||||
`defaultValue mismatch for "${label}"`,
|
||||
);
|
||||
});
|
||||
}
|
||||
405
desktop/src/features/agents/ui/modelCapabilities.ts
Normal file
405
desktop/src/features/agents/ui/modelCapabilities.ts
Normal file
@@ -0,0 +1,405 @@
|
||||
/**
|
||||
* Runtime model-capability interpreter (TypeScript).
|
||||
*
|
||||
* `scripts/model-capabilities.json` is the single source of truth for every
|
||||
* model's six-axis capability profile (thinking mode, supported efforts,
|
||||
* default effort, Databricks v2 wire route, normalization policy, and picker
|
||||
* label). This module imports that manifest and interprets it at runtime,
|
||||
* mirroring the Rust interpreter in `crates/buzz-agent/src/model_capabilities.rs`
|
||||
* line for line. There is no codegen: both interpreters read the same
|
||||
* hand-curated manifest, and the shared normative corpus
|
||||
* (`scripts/normative-corpus.json`) is the cross-language contract that
|
||||
* guarantees they agree.
|
||||
*
|
||||
* ## Resolution algorithm (`resolveModelCapabilities`)
|
||||
* 1. Provider canonicalization (trim, lowercase, alias map) — done by the
|
||||
* caller via `canonicalizeProvider`.
|
||||
* 2. Provider-qualified exact-record lookup (case-insensitive on the id).
|
||||
* 3. Boundary-aware family-rule match: strip any endpoint prefix at the first
|
||||
* family token on a non-alphanumeric boundary, then take the longest match
|
||||
* across every rule's `matchValue` and `matchAliases`, breaking ties on the
|
||||
* lexicographically smallest rule id.
|
||||
* 4. Provider fallback, distinguishing a blank model id from a
|
||||
* concrete-unknown one.
|
||||
*
|
||||
* Every path yields a complete six-axis result; `registryLabel` is populated
|
||||
* only on an exact-record hit.
|
||||
*/
|
||||
import { z } from "zod";
|
||||
import manifestJson from "@model-capabilities-manifest";
|
||||
|
||||
/** Valid thinking-effort values accepted by buzz-agent (mirrors parse_thinking_effort in config.rs). */
|
||||
export const THINKING_EFFORT_VALUES = [
|
||||
"none",
|
||||
"minimal",
|
||||
"low",
|
||||
"medium",
|
||||
"high",
|
||||
"xhigh",
|
||||
"max",
|
||||
] as const;
|
||||
export type ThinkingEffortValue = (typeof THINKING_EFFORT_VALUES)[number];
|
||||
|
||||
/** Databricks v2 wire route. `not-applicable` for non-DBv2 providers. */
|
||||
export type DatabricksV2WireRoute =
|
||||
| "openai-responses"
|
||||
| "anthropic-messages"
|
||||
| "mlflow-chat"
|
||||
| "route-unknown"
|
||||
| "not-applicable";
|
||||
|
||||
/** How a model activates and controls reasoning depth on the wire. */
|
||||
export type ThinkingMode =
|
||||
| "adaptive"
|
||||
| "manual-budget"
|
||||
| "none"
|
||||
| "omit-fields";
|
||||
|
||||
/** Post-resolution effort normalization applied before a request is sent. */
|
||||
export type NormalizationPolicy =
|
||||
| "none"
|
||||
| "openai-standard"
|
||||
| "openai-clamp-max-to-xhigh";
|
||||
|
||||
/** Complete resolved capability record for a (provider, rawModelId) pair. Every axis populated. */
|
||||
export type CapabilityResult = {
|
||||
readonly thinkingMode: ThinkingMode;
|
||||
readonly supportedEfforts: ReadonlyArray<ThinkingEffortValue>;
|
||||
readonly defaultEffort: ThinkingEffortValue | null;
|
||||
readonly databricksV2WireRoute: DatabricksV2WireRoute;
|
||||
readonly normalizationPolicy: NormalizationPolicy;
|
||||
/** Static display label. Populated only on a provider-qualified exact-record hit. */
|
||||
readonly registryLabel: string | null;
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Manifest schema — runtime-validates the bundled manifest, mirroring the
|
||||
// strict serde (`deny_unknown_fields` + real enums) + validate_manifest in the
|
||||
// Rust interpreter. A malformed bundled manifest is a build-time data error
|
||||
// that must never ship, so parse failure throws.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const EffortSchema = z.enum(THINKING_EFFORT_VALUES);
|
||||
const ThinkingModeSchema = z.enum([
|
||||
"adaptive",
|
||||
"manual-budget",
|
||||
"none",
|
||||
"omit-fields",
|
||||
]);
|
||||
const WireRouteSchema = z.enum([
|
||||
"openai-responses",
|
||||
"anthropic-messages",
|
||||
"mlflow-chat",
|
||||
"route-unknown",
|
||||
"not-applicable",
|
||||
]);
|
||||
const NormalizationSchema = z.enum([
|
||||
"none",
|
||||
"openai-standard",
|
||||
"openai-clamp-max-to-xhigh",
|
||||
]);
|
||||
|
||||
const FamilyRuleSchema = z
|
||||
.object({
|
||||
id: z.string(),
|
||||
match_kind: z.enum(["exact", "prefix"]),
|
||||
match_value: z.string(),
|
||||
match_aliases: z.array(z.string()).default([]),
|
||||
providers: z.array(z.string()),
|
||||
thinking_mode: ThinkingModeSchema,
|
||||
supported_efforts: z.array(EffortSchema),
|
||||
default_effort: EffortSchema.nullable(),
|
||||
databricks_v2_wire_route: WireRouteSchema,
|
||||
normalization_policy: NormalizationSchema,
|
||||
// Documentation-only key; modeled so strict parsing accepts the manifest
|
||||
// while still rejecting an unmodeled (typo'd) field. Mirrors the Rust
|
||||
// `FamilyRule` doc field under `deny_unknown_fields`.
|
||||
_comment: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const ExactRecordSchema = z
|
||||
.object({
|
||||
provider: z.string(),
|
||||
raw_model_id: z.string(),
|
||||
registry_label: z.string(),
|
||||
thinking_mode: ThinkingModeSchema,
|
||||
supported_efforts: z.array(EffortSchema),
|
||||
default_effort: EffortSchema.nullable(),
|
||||
databricks_v2_wire_route: WireRouteSchema,
|
||||
normalization_policy: NormalizationSchema,
|
||||
// Documentation/provenance keys; modeled for strict parsing, not read at
|
||||
// runtime. Mirrors the Rust `ExactRecord` doc fields under
|
||||
// `deny_unknown_fields`.
|
||||
_provenance: z.string().optional(),
|
||||
source: z.string().optional(),
|
||||
_source: z.string().optional(),
|
||||
_reconciliation: z.string().optional(),
|
||||
_reconciliation_note: z.string().optional(),
|
||||
_reconciliation_doc: z.string().optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
const FallbackStateSchema = z
|
||||
.object({
|
||||
databricks_v2_wire_route: WireRouteSchema,
|
||||
thinking_mode: ThinkingModeSchema,
|
||||
supported_efforts: z.array(EffortSchema),
|
||||
default_effort: EffortSchema.nullable(),
|
||||
normalization_policy: NormalizationSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
const FallbackPairSchema = z
|
||||
.object({
|
||||
blank: FallbackStateSchema,
|
||||
concrete_unknown: FallbackStateSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
// Fixed named providers with a `_default` catch-all, mirroring the Rust
|
||||
// `ProviderFallbacks` struct. Enumerating the keys structurally guarantees
|
||||
// `_default` is present (the resolver's total-function backstop).
|
||||
const ProviderFallbacksSchema = z
|
||||
.object({
|
||||
anthropic: FallbackPairSchema,
|
||||
openai: FallbackPairSchema,
|
||||
databricks: FallbackPairSchema,
|
||||
databricks_v2: FallbackPairSchema,
|
||||
openrouter: FallbackPairSchema,
|
||||
_default: FallbackPairSchema,
|
||||
})
|
||||
.strict();
|
||||
|
||||
export const ManifestSchema = z
|
||||
.object({
|
||||
family_tokens: z.array(z.string()).min(1),
|
||||
family_rules: z.array(FamilyRuleSchema),
|
||||
databricks_v2_known_models: z.array(z.string()),
|
||||
exact_records: z.array(ExactRecordSchema),
|
||||
provider_fallbacks: ProviderFallbacksSchema,
|
||||
// Root documentation keys; modeled for strict parsing, not read at runtime.
|
||||
// Mirrors the Rust `Manifest` doc fields under `deny_unknown_fields`.
|
||||
_comment: z.string().optional(),
|
||||
_comment_databricks_v2_known_models: z.string().optional(),
|
||||
_sources: z.record(z.string(), z.string()).optional(),
|
||||
})
|
||||
.strict();
|
||||
|
||||
type ParsedManifest = z.infer<typeof ManifestSchema>;
|
||||
type FamilyRule = z.infer<typeof FamilyRuleSchema>;
|
||||
type FallbackState = z.infer<typeof FallbackStateSchema>;
|
||||
type FallbackPair = z.infer<typeof FallbackPairSchema>;
|
||||
|
||||
const MANIFEST: ParsedManifest = ManifestSchema.parse(manifestJson);
|
||||
|
||||
// Prototype-safe provider→fallback lookup. A plain-object index would return
|
||||
// `Object.prototype.constructor` / `Object.prototype` for the adversarial
|
||||
// providers `constructor` / `__proto__`, defeating the `_default` catch-all;
|
||||
// a Map keys only on real entries. Mirrors `ProviderFallbacks::get`.
|
||||
const PROVIDER_FALLBACKS: ReadonlyMap<string, FallbackPair> = new Map(
|
||||
Object.entries(MANIFEST.provider_fallbacks),
|
||||
);
|
||||
|
||||
function fallbackPair(canon: string): FallbackPair {
|
||||
// `_default` is a required key on `ProviderFallbacksSchema`, so referencing
|
||||
// it directly is typed as a non-optional `FallbackPair` — no assertion, and
|
||||
// the total-function backstop is guaranteed by the schema, not by `!`.
|
||||
return PROVIDER_FALLBACKS.get(canon) ?? MANIFEST.provider_fallbacks._default;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Provider canonicalization
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
const PROVIDER_ALIASES = new Map<string, string>([
|
||||
["openai-compat", "openai"],
|
||||
["databricks-v2", "databricks_v2"],
|
||||
]);
|
||||
|
||||
/** Canonicalize a provider name: trim, lowercase, apply the alias map. */
|
||||
export function canonicalizeProvider(provider: string): string {
|
||||
const canon = provider.trim().toLowerCase();
|
||||
return PROVIDER_ALIASES.get(canon) ?? canon;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Boundary-aware prefix helpers — mirror strip_catalog_prefix / prefix_matches.
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function isAsciiAlphanumeric(ch: string): boolean {
|
||||
return /^[a-z0-9]$/i.test(ch);
|
||||
}
|
||||
|
||||
/**
|
||||
* Strip an endpoint-naming prefix by locating the earliest family token that
|
||||
* begins on a non-alphanumeric boundary (or at the start), returning the slice
|
||||
* from that token onward. Returns the input unchanged when no token qualifies.
|
||||
*/
|
||||
export function stripCatalogPrefix(
|
||||
modelLower: string,
|
||||
familyTokens: ReadonlyArray<string>,
|
||||
): string {
|
||||
let best = Number.POSITIVE_INFINITY;
|
||||
for (const tok of familyTokens) {
|
||||
let from = 0;
|
||||
while (true) {
|
||||
const idx = modelLower.indexOf(tok, from);
|
||||
if (idx === -1) break;
|
||||
if (idx === 0 || !isAsciiAlphanumeric(modelLower[idx - 1])) {
|
||||
if (idx < best) best = idx;
|
||||
break;
|
||||
}
|
||||
from = idx + 1;
|
||||
}
|
||||
}
|
||||
return best === Number.POSITIVE_INFINITY
|
||||
? modelLower
|
||||
: modelLower.slice(best);
|
||||
}
|
||||
|
||||
/**
|
||||
* Boundary-aware prefix test: `s` equals `token`, or `s` starts with `token`
|
||||
* and the following character is a non-alphanumeric boundary.
|
||||
*/
|
||||
function prefixMatches(token: string, s: string): boolean {
|
||||
if (!s.startsWith(token)) return false;
|
||||
const rest = s.slice(token.length);
|
||||
return rest.length === 0 || !isAsciiAlphanumeric(rest[0]);
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Resolution
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function toResult(
|
||||
axes: FamilyRule | FallbackState,
|
||||
route: DatabricksV2WireRoute,
|
||||
registryLabel: string | null,
|
||||
): CapabilityResult {
|
||||
return {
|
||||
thinkingMode: axes.thinking_mode,
|
||||
supportedEfforts: axes.supported_efforts,
|
||||
defaultEffort: axes.default_effort,
|
||||
databricksV2WireRoute: route,
|
||||
normalizationPolicy: axes.normalization_policy,
|
||||
registryLabel,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* Resolve the capability profile for a `(provider, rawModelId)` pair.
|
||||
*
|
||||
* Total function — always returns a complete result. Provider canonicalization
|
||||
* happens inside the resolver (trim, lowercase, alias map), so callers pass raw
|
||||
* provider names. Mirrors `resolve` in the Rust interpreter exactly.
|
||||
*/
|
||||
export function resolveModelCapabilities(
|
||||
provider: string,
|
||||
rawModelId: string,
|
||||
): CapabilityResult {
|
||||
const canon = canonicalizeProvider(provider);
|
||||
const blank = rawModelId.trim().length === 0;
|
||||
|
||||
// 1. Provider-qualified exact-record lookup (case-insensitive on the id).
|
||||
if (!blank) {
|
||||
const idLower = rawModelId.toLowerCase();
|
||||
for (const rec of MANIFEST.exact_records) {
|
||||
if (
|
||||
rec.provider === canon &&
|
||||
rec.raw_model_id.toLowerCase() === idLower
|
||||
) {
|
||||
return toResult(rec, rec.databricks_v2_wire_route, rec.registry_label);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 2. Boundary-aware family match: longest token wins, lexicographic tie-break.
|
||||
if (!blank) {
|
||||
const modelLower = rawModelId.toLowerCase();
|
||||
const stripped = stripCatalogPrefix(modelLower, MANIFEST.family_tokens);
|
||||
let best: { len: number; rule: FamilyRule } | null = null;
|
||||
for (const rule of MANIFEST.family_rules) {
|
||||
if (!rule.providers.includes(canon)) continue;
|
||||
let matched: number | null = null;
|
||||
for (const tok of [rule.match_value, ...rule.match_aliases]) {
|
||||
const ok =
|
||||
rule.match_kind === "exact"
|
||||
? stripped === tok
|
||||
: prefixMatches(tok, stripped);
|
||||
if (ok)
|
||||
matched =
|
||||
matched === null ? tok.length : Math.max(matched, tok.length);
|
||||
}
|
||||
if (matched !== null) {
|
||||
const better =
|
||||
best === null ||
|
||||
matched > best.len ||
|
||||
(matched === best.len && rule.id < best.rule.id);
|
||||
if (better) best = { len: matched, rule };
|
||||
}
|
||||
}
|
||||
if (best !== null) {
|
||||
const route: DatabricksV2WireRoute =
|
||||
canon === "databricks_v2"
|
||||
? best.rule.databricks_v2_wire_route
|
||||
: "not-applicable";
|
||||
return toResult(best.rule, route, null);
|
||||
}
|
||||
}
|
||||
|
||||
// 3. Provider fallback (blank vs. concrete-unknown); never carries a label.
|
||||
const pair = fallbackPair(canon);
|
||||
const state = blank ? pair.blank : pair.concrete_unknown;
|
||||
return toResult(state, state.databricks_v2_wire_route, null);
|
||||
}
|
||||
|
||||
/** Authoritative list of known Databricks v2 model ids, sourced from the manifest. */
|
||||
export const DATABRICKS_V2_KNOWN_MODELS: ReadonlyArray<string> =
|
||||
MANIFEST.databricks_v2_known_models;
|
||||
|
||||
export type RegistryLabelRecord = {
|
||||
readonly provider: string;
|
||||
readonly raw_model_id: string;
|
||||
readonly registry_label: string;
|
||||
};
|
||||
|
||||
export function databricksRegistryLabelForRecords(
|
||||
rawModelId: string,
|
||||
records: ReadonlyArray<RegistryLabelRecord>,
|
||||
familyTokens: ReadonlyArray<string>,
|
||||
): string | null {
|
||||
if (!rawModelId.trim()) return null;
|
||||
|
||||
const idLower = rawModelId.toLowerCase();
|
||||
const exact = records.find(
|
||||
(rec) =>
|
||||
rec.provider === "databricks_v2" &&
|
||||
rec.raw_model_id.toLowerCase() === idLower,
|
||||
);
|
||||
if (exact) return exact.registry_label;
|
||||
|
||||
const strippedQuery = stripCatalogPrefix(idLower, familyTokens);
|
||||
if (strippedQuery === idLower) return null;
|
||||
let matchingRecord: RegistryLabelRecord | null = null;
|
||||
for (const rec of records) {
|
||||
if (rec.provider !== "databricks_v2") continue;
|
||||
const strippedRecord = stripCatalogPrefix(
|
||||
rec.raw_model_id.toLowerCase(),
|
||||
familyTokens,
|
||||
);
|
||||
if (strippedRecord === strippedQuery) {
|
||||
if (matchingRecord) return null;
|
||||
matchingRecord = rec;
|
||||
}
|
||||
}
|
||||
return matchingRecord?.registry_label ?? null;
|
||||
}
|
||||
|
||||
export function databricksRegistryLabel(rawModelId: string): string | null {
|
||||
return databricksRegistryLabelForRecords(
|
||||
rawModelId,
|
||||
MANIFEST.exact_records,
|
||||
MANIFEST.family_tokens,
|
||||
);
|
||||
}
|
||||
156
desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs
Normal file
156
desktop/src/features/agents/ui/modelCapabilitiesCorpus.test.mjs
Normal file
@@ -0,0 +1,156 @@
|
||||
import assert from "node:assert/strict";
|
||||
import { readFileSync } from "node:fs";
|
||||
import { fileURLToPath } from "node:url";
|
||||
import test from "node:test";
|
||||
|
||||
import {
|
||||
databricksRegistryLabelForRecords,
|
||||
ManifestSchema,
|
||||
resolveModelCapabilities,
|
||||
} from "./modelCapabilities.ts";
|
||||
|
||||
// The normative corpus (`scripts/normative-corpus.json`) is the cross-language
|
||||
// contract: the Rust interpreter's test suite runs the same executable vectors
|
||||
// through its `resolve`, so a green run here proves the TS interpreter agrees
|
||||
// with Rust axis-for-axis. Loaded by relative path — the corpus never passes
|
||||
// through vite/tsc, so it needs no import alias.
|
||||
const corpusUrl = new URL(
|
||||
"../../../../../scripts/normative-corpus.json",
|
||||
import.meta.url,
|
||||
);
|
||||
const corpus = JSON.parse(readFileSync(fileURLToPath(corpusUrl), "utf8"));
|
||||
|
||||
// A vector is executable iff it carries an `expect` block; section markers
|
||||
// (`_group`) are skipped. Mirrors the Rust corpus filter.
|
||||
const executable = corpus.filter((entry) => entry.expect != null);
|
||||
|
||||
test("corpus has exactly 113 executable vectors", () => {
|
||||
// Locks the vector count so a silent corpus edit can't quietly drop coverage;
|
||||
// must equal the gate in the Rust suite (model_capabilities.rs).
|
||||
assert.equal(executable.length, 113);
|
||||
});
|
||||
|
||||
test("registry label aliases refuse an unprefixed query", () => {
|
||||
const records = [
|
||||
{
|
||||
provider: "databricks_v2",
|
||||
raw_model_id: "databricks-gpt-5",
|
||||
registry_label: "GPT-5",
|
||||
},
|
||||
];
|
||||
assert.equal(
|
||||
databricksRegistryLabelForRecords("gpt-5", records, ["gpt-"]),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("registry label aliases refuse ambiguous stripped record keys", () => {
|
||||
const records = [
|
||||
{
|
||||
provider: "databricks_v2",
|
||||
raw_model_id: "databricks-gpt-5-6",
|
||||
registry_label: "Databricks GPT-5.6",
|
||||
},
|
||||
{
|
||||
provider: "databricks_v2",
|
||||
raw_model_id: "partner-gpt-5-6",
|
||||
registry_label: "Partner GPT-5.6",
|
||||
},
|
||||
];
|
||||
assert.equal(
|
||||
databricksRegistryLabelForRecords("goose-gpt-5-6", records, ["gpt-"]),
|
||||
null,
|
||||
);
|
||||
});
|
||||
|
||||
test("every executable corpus vector resolves to its expected six-axis profile", () => {
|
||||
for (const entry of executable) {
|
||||
const id = entry.id ?? "<no-id>";
|
||||
const got = resolveModelCapabilities(
|
||||
entry.provider ?? "",
|
||||
entry.raw_model_id ?? "",
|
||||
);
|
||||
const want = entry.expect;
|
||||
assert.equal(got.thinkingMode, want.thinking_mode, `${id}: thinkingMode`);
|
||||
assert.deepEqual(
|
||||
[...got.supportedEfforts],
|
||||
want.supported_efforts,
|
||||
`${id}: supportedEfforts`,
|
||||
);
|
||||
assert.equal(
|
||||
got.defaultEffort,
|
||||
want.default_effort,
|
||||
`${id}: defaultEffort`,
|
||||
);
|
||||
assert.equal(
|
||||
got.databricksV2WireRoute,
|
||||
want.databricks_v2_wire_route,
|
||||
`${id}: databricksV2WireRoute`,
|
||||
);
|
||||
assert.equal(
|
||||
got.normalizationPolicy,
|
||||
want.normalization_policy,
|
||||
`${id}: normalizationPolicy`,
|
||||
);
|
||||
assert.equal(
|
||||
got.registryLabel,
|
||||
want.registry_label,
|
||||
`${id}: registryLabel`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
test("registryLabel axis is exercised by at least 12 exact-record vectors", () => {
|
||||
// The registryLabel axis only populates on an exact-record hit; guard that
|
||||
// the corpus keeps covering it so a regression there can't pass unnoticed.
|
||||
const labeled = executable.filter((e) => e.expect.registry_label != null);
|
||||
assert.ok(
|
||||
labeled.length >= 12,
|
||||
`expected >=12 labeled vectors, got ${labeled.length}`,
|
||||
);
|
||||
for (const entry of labeled) {
|
||||
const got = resolveModelCapabilities(
|
||||
entry.provider ?? "",
|
||||
entry.raw_model_id ?? "",
|
||||
);
|
||||
assert.equal(
|
||||
got.registryLabel,
|
||||
entry.expect.registry_label,
|
||||
`${entry.id ?? "<no-id>"}: registryLabel`,
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
// The TS manifest schema mirrors Rust's `#[serde(deny_unknown_fields)]`: a
|
||||
// misspelled key must fail in BOTH languages, not pass silently on desktop.
|
||||
// Loaded by relative path — same rationale as the corpus above.
|
||||
const manifestUrl = new URL(
|
||||
"../../../../../scripts/model-capabilities.json",
|
||||
import.meta.url,
|
||||
);
|
||||
const manifestJson = JSON.parse(
|
||||
readFileSync(fileURLToPath(manifestUrl), "utf8"),
|
||||
);
|
||||
|
||||
test("ManifestSchema accepts the committed manifest verbatim", () => {
|
||||
// The strict schema must model every documented key the manifest actually
|
||||
// ships (`_comment`, `_provenance`, `source`, `_sources`, …); a green parse
|
||||
// here proves strictness didn't over-reach and break the real data.
|
||||
assert.doesNotThrow(() => ManifestSchema.parse(manifestJson));
|
||||
});
|
||||
|
||||
test("ManifestSchema rejects an unknown top-level field", () => {
|
||||
// Mirrors Rust `deny_unknown_fields`: a typo'd root key is a hard error, not
|
||||
// an ignored no-op. Without `.strict()` this passed on desktop while Rust
|
||||
// failed — the exact drift this alignment closes.
|
||||
const withTypo = { ...manifestJson, faimly_rules: [] };
|
||||
assert.throws(() => ManifestSchema.parse(withTypo));
|
||||
});
|
||||
|
||||
test("ManifestSchema rejects an unknown field inside an exact record", () => {
|
||||
// Strictness must reach nested objects too, not just the root — an exact
|
||||
// record with a stray key is where a hand-edit typo most plausibly lands.
|
||||
const mutated = structuredClone(manifestJson);
|
||||
mutated.exact_records[0].raw_modle_id = "typo";
|
||||
assert.throws(() => ManifestSchema.parse(mutated));
|
||||
});
|
||||
@@ -1,7 +1,12 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
import React from "react";
|
||||
import { renderToStaticMarkup } from "react-dom/server";
|
||||
|
||||
import { resolveCatalogOwnerLabel } from "./PersonaCatalogDialog.tsx";
|
||||
import {
|
||||
AgentInstructionReview,
|
||||
resolveCatalogOwnerLabel,
|
||||
} from "./PersonaCatalogDialog.tsx";
|
||||
|
||||
// ── null / undefined summary ──────────────────────────────────────────────────
|
||||
|
||||
@@ -75,3 +80,26 @@ test("test_display_name_null_name_present_returns_name", () => {
|
||||
"alice",
|
||||
);
|
||||
});
|
||||
|
||||
test("agent instruction review renders markdown concealment syntax literally", () => {
|
||||
const instructions = [
|
||||
"Review changes.",
|
||||
"||Hidden spoiler instruction.||",
|
||||
"[Benign label](https://example.com/hidden-instruction)",
|
||||
"",
|
||||
].join("\n");
|
||||
const html = renderToStaticMarkup(
|
||||
React.createElement(AgentInstructionReview, { instructions }),
|
||||
);
|
||||
|
||||
assert.ok(html.includes("||Hidden spoiler instruction.||"));
|
||||
assert.ok(
|
||||
html.includes("[Benign label](https://example.com/hidden-instruction)"),
|
||||
);
|
||||
assert.ok(
|
||||
html.includes(""),
|
||||
);
|
||||
assert.ok(!html.includes("buzz-spoiler"));
|
||||
assert.ok(!html.includes("<a"));
|
||||
assert.ok(!html.includes("<img"));
|
||||
});
|
||||
|
||||
@@ -332,6 +332,36 @@ test("a policy-only persona still seeds a behavior group", () => {
|
||||
});
|
||||
});
|
||||
|
||||
test("a linked instance overrides stale definition access in the edit dialog", () => {
|
||||
const persona = {
|
||||
id: "persona-instance-access",
|
||||
displayName: "Shared",
|
||||
avatarUrl: null,
|
||||
systemPrompt: "Shared.",
|
||||
runtime: null,
|
||||
model: null,
|
||||
provider: null,
|
||||
isBuiltIn: false,
|
||||
isActive: true,
|
||||
respondTo: "owner-only",
|
||||
respondToAllowlist: [],
|
||||
parallelism: 2,
|
||||
createdAt: "2025-01-01T00:00:00Z",
|
||||
updatedAt: "2025-01-02T00:00:00Z",
|
||||
};
|
||||
|
||||
const state = editPersonaDialogState(persona, {
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: ["c".repeat(64)],
|
||||
});
|
||||
|
||||
assert.deepEqual(state.initialValues.behavior, {
|
||||
respondTo: "allowlist",
|
||||
respondToAllowlist: ["c".repeat(64)],
|
||||
parallelism: 2,
|
||||
});
|
||||
});
|
||||
|
||||
test("a non-allowlist mode does not seed a stale allowlist into the dialog", () => {
|
||||
const state = editPersonaDialogState({
|
||||
id: "persona-mode-flip",
|
||||
|
||||
@@ -109,7 +109,15 @@ function behaviorEntry(
|
||||
|
||||
export function editPersonaDialogState(
|
||||
persona: AgentPersona,
|
||||
accessSource?: Pick<AgentPersona, "respondTo" | "respondToAllowlist">,
|
||||
): PersonaDialogState {
|
||||
const behaviorSource = accessSource
|
||||
? {
|
||||
...persona,
|
||||
respondTo: accessSource.respondTo,
|
||||
respondToAllowlist: accessSource.respondToAllowlist,
|
||||
}
|
||||
: persona;
|
||||
return {
|
||||
title: "Edit agent",
|
||||
description: "",
|
||||
@@ -128,7 +136,7 @@ export function editPersonaDialogState(
|
||||
// the dialog must therefore round-trip the existing values.)
|
||||
namePool: persona.namePool ?? [],
|
||||
envVars: persona.envVars ?? {},
|
||||
...behaviorEntry(persona),
|
||||
...behaviorEntry(behaviorSource),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
77
desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs
Normal file
77
desktop/src/features/agents/ui/unifiedAgentGroups.test.mjs
Normal file
@@ -0,0 +1,77 @@
|
||||
import assert from "node:assert/strict";
|
||||
import test from "node:test";
|
||||
|
||||
import { buildUnifiedGroups } from "./unifiedAgentGroups.ts";
|
||||
|
||||
const NONE_ARCHIVED = () => false;
|
||||
|
||||
function agent(overrides = {}) {
|
||||
return {
|
||||
name: "Agent",
|
||||
pubkey: "a".repeat(64),
|
||||
personaId: null,
|
||||
status: "stopped",
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function persona(overrides = {}) {
|
||||
return { id: "persona-1", displayName: "Persona", ...overrides };
|
||||
}
|
||||
|
||||
test("archived standalone custom agents are omitted while live peers remain", () => {
|
||||
const archived = agent({ pubkey: "a".repeat(64), personaId: null });
|
||||
const live = agent({ pubkey: "b".repeat(64), personaId: null });
|
||||
const isArchived = (pubkey) => pubkey === archived.pubkey;
|
||||
|
||||
const { ungrouped } = buildUnifiedGroups([], [archived, live], isArchived);
|
||||
|
||||
assert.deepEqual(
|
||||
ungrouped.map((agent) => agent.pubkey),
|
||||
[live.pubkey],
|
||||
);
|
||||
});
|
||||
|
||||
test("archived unknown-persona agents are omitted while live peers remain", () => {
|
||||
const archived = agent({ pubkey: "a".repeat(64), personaId: "orphan" });
|
||||
const live = agent({ pubkey: "b".repeat(64), personaId: "orphan" });
|
||||
const isArchived = (pubkey) => pubkey === archived.pubkey;
|
||||
|
||||
// No persona matches "orphan", so both land in the unknown bucket.
|
||||
const { unknown } = buildUnifiedGroups([], [archived, live], isArchived);
|
||||
|
||||
assert.deepEqual(
|
||||
unknown.map((agent) => agent.pubkey),
|
||||
[live.pubkey],
|
||||
);
|
||||
});
|
||||
|
||||
test("matched persona groups keep their full instance list including archived", () => {
|
||||
const archived = agent({ pubkey: "a".repeat(64), personaId: "persona-1" });
|
||||
const live = agent({ pubkey: "b".repeat(64), personaId: "persona-1" });
|
||||
const isArchived = (pubkey) => pubkey === archived.pubkey;
|
||||
|
||||
// The card resolves its own target via pickProfileAgent; the group keeps the
|
||||
// archived record so an all-archived persona still forms a card in
|
||||
// persona-only mode rather than vanishing from the library.
|
||||
const { groups } = buildUnifiedGroups(
|
||||
[persona()],
|
||||
[archived, live],
|
||||
isArchived,
|
||||
);
|
||||
|
||||
assert.equal(groups.length, 1);
|
||||
assert.deepEqual(
|
||||
groups[0].agents.map((agent) => agent.pubkey).sort(),
|
||||
[archived.pubkey, live.pubkey].sort(),
|
||||
);
|
||||
});
|
||||
|
||||
test("a fail-open predicate keeps every standalone agent discoverable", () => {
|
||||
const first = agent({ pubkey: "a".repeat(64), personaId: null });
|
||||
const second = agent({ pubkey: "b".repeat(64), personaId: null });
|
||||
|
||||
const { ungrouped } = buildUnifiedGroups([], [first, second], NONE_ARCHIVED);
|
||||
|
||||
assert.equal(ungrouped.length, 2);
|
||||
});
|
||||
@@ -1,18 +1,29 @@
|
||||
import { isManagedAgentActive } from "@/features/agents/lib/managedAgentControlActions";
|
||||
import type { AgentPersona, ManagedAgent } from "@/shared/api/types";
|
||||
|
||||
type PersonaGroup = { persona: AgentPersona; agents: ManagedAgent[] };
|
||||
|
||||
/**
|
||||
* Group managed agents under their personas for the Agents library.
|
||||
*
|
||||
* Archived instances are dropped from the standalone `ungrouped` (custom
|
||||
* agents) and `unknown` buckets so a relay-archived identity never shows as a
|
||||
* clickable library card of its own. Matched persona groups keep their full
|
||||
* instance list — the persona card resolves its own target through
|
||||
* `pickProfileAgent`, which applies the same `isArchived` filter and falls back
|
||||
* to persona-only mode when every instance is archived. `isArchived` is
|
||||
* fail-open (returns `false` while the relay archive snapshot loads).
|
||||
*/
|
||||
export function buildUnifiedGroups(
|
||||
personas: AgentPersona[],
|
||||
agents: ManagedAgent[],
|
||||
isArchived: (pubkey: string) => boolean,
|
||||
) {
|
||||
const byPersonaId = new Map<string, ManagedAgent[]>();
|
||||
const ungrouped: ManagedAgent[] = [];
|
||||
|
||||
for (const agent of agents) {
|
||||
if (!agent.personaId) {
|
||||
ungrouped.push(agent);
|
||||
if (!isArchived(agent.pubkey)) ungrouped.push(agent);
|
||||
} else {
|
||||
const list = byPersonaId.get(agent.personaId) ?? [];
|
||||
list.push(agent);
|
||||
@@ -28,17 +39,10 @@ export function buildUnifiedGroups(
|
||||
|
||||
const unknown: ManagedAgent[] = [];
|
||||
for (const [id, list] of byPersonaId) {
|
||||
if (!matched.has(id)) unknown.push(...list);
|
||||
if (!matched.has(id)) {
|
||||
unknown.push(...list.filter((agent) => !isArchived(agent.pubkey)));
|
||||
}
|
||||
}
|
||||
|
||||
return { groups, ungrouped, unknown };
|
||||
}
|
||||
|
||||
export function pickProfileAgent(agents: ManagedAgent[]) {
|
||||
return [...agents].sort((left, right) => {
|
||||
const activeDiff =
|
||||
Number(isManagedAgentActive(right)) - Number(isManagedAgentActive(left));
|
||||
if (activeDiff !== 0) return activeDiff;
|
||||
return left.name.localeCompare(right.name);
|
||||
})[0];
|
||||
}
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user