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:
Duncan
2026-08-25 12:16:11 -04:00
1590 changed files with 218798 additions and 33084 deletions

View File

@@ -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);

View File

@@ -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>
);
}

View File

@@ -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 = [];

View File

@@ -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;

View File

@@ -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>

View File

@@ -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}

View File

@@ -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}

View File

@@ -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>
);
}

View 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;
}

View 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>
);
}

View 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]);
}

View 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);
});

View 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);
};
}

View 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);
});

View 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,
});
}

View File

@@ -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,

View File

@@ -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) => {

View File

@@ -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 />;
}

View File

@@ -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}
/>
);
}

View 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 };
});

View File

@@ -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>
);

View File

@@ -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>
);
}

View File

@@ -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>
);
}

View File

@@ -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]);

View File

@@ -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,

View File

@@ -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,
]);
}

View 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);
});

View 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);
}, []);
}

View File

@@ -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);
};
}, []);
}

View File

@@ -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;
}

View File

@@ -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

View 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();
});
});
});

View 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,
};
}

View File

@@ -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(() => {

View File

@@ -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]);
}

View File

@@ -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,
);
});

View File

@@ -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,

View File

@@ -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;
}

View File

@@ -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({

View File

@@ -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) {

View File

@@ -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)");
});

View File

@@ -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";
}

View File

@@ -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,
);
});

View File

@@ -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);
}

View File

@@ -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");
});

View File

@@ -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");
}
});

View 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,
);
});

View 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),
);
}

View File

@@ -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", {}]]);
});

View File

@@ -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);
});

View File

@@ -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(

View 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);
});

View 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;
}

View File

@@ -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",
);
});

View File

@@ -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);
});

View File

@@ -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"),

View File

@@ -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[],

View File

@@ -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) {

View File

@@ -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;
});

View File

@@ -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);

View File

@@ -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();

View 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",
);
});
});

View File

@@ -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.",
);
}
}

View File

@@ -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}

View File

@@ -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>
);
}

View File

@@ -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/);
});

View File

@@ -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}
/>
);

View File

@@ -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"

View File

@@ -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}

View 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>
);
}

View File

@@ -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>

View 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/);
});

View 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>
);
}

View File

@@ -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>

View 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>
);
}

View File

@@ -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>
);

View File

@@ -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.`,
);
}
}

View File

@@ -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>

View File

@@ -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";

View File

@@ -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={() => {

View File

@@ -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" } }]);
});

View File

@@ -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).

View File

@@ -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,

View File

@@ -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.`,
);
}

View File

@@ -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.

View File

@@ -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.",

View File

@@ -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 });
}

View File

@@ -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");
});

View File

@@ -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,
};
}

View 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");
});

View 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;
}

View File

@@ -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"
}
]

View File

@@ -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}"`,
);
});
}

View 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,
);
}

View 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));
});

View File

@@ -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)",
"![Image label](https://example.com/hidden-image-source)",
].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("![Image label](https://example.com/hidden-image-source)"),
);
assert.ok(!html.includes("buzz-spoiler"));
assert.ok(!html.includes("<a"));
assert.ok(!html.includes("<img"));
});

View File

@@ -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",

View File

@@ -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),
},
};
}

View 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);
});

View File

@@ -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