refactor: update goose2 credential management behind provider-scoped ACP/core API (#8887)

Signed-off-by: morgmart <98432065+morgmart@users.noreply.github.com>
Co-authored-by: morgmart <98432065+morgmart@users.noreply.github.com>
This commit is contained in:
Kalvin C
2026-04-29 09:05:32 -07:00
committed by GitHub
parent 503ad20494
commit 899762a422
39 changed files with 3079 additions and 1118 deletions
+5 -26
View File
@@ -7,12 +7,6 @@ import { setNotificationHandler, getClient } from "@/shared/api/acpConnection";
import notificationHandler from "@/shared/api/acpNotificationHandler";
import { perfLog } from "@/shared/lib/perfLog";
const INVENTORY_POLL_DELAYS_MS = [250, 500, 750, 1000, 1500, 2000];
function sleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
export function useAppStartup() {
useEffect(() => {
(async () => {
@@ -101,27 +95,12 @@ export function useAppStartup() {
return;
}
const { getProviderInventory, refreshProviderInventory } =
await import("@/features/providers/api/inventory");
const refresh = await refreshProviderInventory(configuredProviderIds);
if (refresh.started.length === 0) {
return;
}
inventoryStore.mergeEntries(
await getProviderInventory(refresh.started),
const { syncProviderInventory } = await import(
"@/features/providers/api/inventorySync"
);
for (const delayMs of INVENTORY_POLL_DELAYS_MS) {
await sleep(delayMs);
const refreshedEntries = await getProviderInventory(
refresh.started,
);
inventoryStore.mergeEntries(refreshedEntries);
if (refreshedEntries.every((entry) => !entry.refreshing)) {
return;
}
}
await syncProviderInventory(configuredProviderIds, {
onEntries: (entries) => inventoryStore.mergeEntries(entries),
});
} catch (err) {
console.error(
"Failed to refresh provider inventory on startup:",
@@ -0,0 +1,126 @@
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
checkAllProviderStatus,
deleteProviderConfig,
getProviderConfig,
saveProviderConfig,
} from "./credentials";
const mocks = vi.hoisted(() => ({
configRead: vi.fn(),
configSave: vi.fn(),
configDelete: vi.fn(),
configStatus: vi.fn(),
getClient: vi.fn(),
}));
vi.mock("@/shared/api/acpConnection", () => ({
getClient: () => mocks.getClient(),
}));
describe("provider credential API", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.getClient.mockResolvedValue({
goose: {
GooseProvidersConfigRead: mocks.configRead,
GooseProvidersConfigSave: mocks.configSave,
GooseProvidersConfigDelete: mocks.configDelete,
GooseProvidersConfigStatus: mocks.configStatus,
},
});
});
it("reads provider config fields through the ACP provider config endpoint", async () => {
const fields = [
{
key: "ANTHROPIC_API_KEY",
value: "sk-ant-********",
isSet: true,
isSecret: true,
required: true,
},
];
mocks.configRead.mockResolvedValue({ fields });
await expect(getProviderConfig("anthropic")).resolves.toEqual(fields);
expect(mocks.configRead).toHaveBeenCalledWith({
providerId: "anthropic",
});
});
it("saves provider config fields as one batch through ACP", async () => {
const fields = [
{
key: "ANTHROPIC_API_KEY",
value: "sk-ant-test",
},
{
key: "ANTHROPIC_HOST",
value: "https://api.anthropic.com",
},
];
const response = {
status: {
providerId: "anthropic",
isConfigured: true,
},
refresh: {
started: ["anthropic"],
skipped: [],
},
};
mocks.configSave.mockResolvedValue(response);
await expect(saveProviderConfig("anthropic", fields)).resolves.toEqual(
response,
);
expect(mocks.configSave).toHaveBeenCalledWith({
providerId: "anthropic",
fields,
});
});
it("deletes provider config through ACP", async () => {
const response = {
status: {
providerId: "anthropic",
isConfigured: false,
},
refresh: {
started: [],
skipped: [
{
providerId: "anthropic",
reason: "not_configured",
},
],
},
};
mocks.configDelete.mockResolvedValue(response);
await expect(deleteProviderConfig("anthropic")).resolves.toEqual(response);
expect(mocks.configDelete).toHaveBeenCalledWith({
providerId: "anthropic",
});
});
it("checks provider status through ACP", async () => {
const statuses = [
{
providerId: "anthropic",
isConfigured: true,
},
];
mocks.configStatus.mockResolvedValue({ statuses });
await expect(checkAllProviderStatus()).resolves.toEqual(statuses);
expect(mocks.configStatus).toHaveBeenCalledWith({
providerIds: [],
});
});
});
@@ -1,32 +1,44 @@
import { invoke } from "@tauri-apps/api/core";
import type {
ProviderConfigChangeResponse,
ProviderConfigFieldUpdate,
ProviderConfigStatusDto,
} from "@aaif/goose-sdk";
import type { ProviderFieldValue } from "@/shared/types/providers";
import { getClient } from "@/shared/api/acpConnection";
export interface ProviderStatus {
providerId: string;
isConfigured: boolean;
}
export type ProviderStatus = ProviderConfigStatusDto;
export type ProviderFieldSaveInput = ProviderConfigFieldUpdate;
export async function getProviderConfig(
providerId: string,
): Promise<ProviderFieldValue[]> {
return invoke("get_provider_config", { providerId });
const client = await getClient();
const response = await client.goose.GooseProvidersConfigRead({ providerId });
return response.fields.map((field) => ({
...field,
value: field.value ?? null,
}));
}
export async function saveProviderField(
key: string,
value: string,
): Promise<void> {
return invoke("save_provider_field", { key, value });
export async function saveProviderConfig(
providerId: string,
fields: ProviderFieldSaveInput[],
): Promise<ProviderConfigChangeResponse> {
const client = await getClient();
return client.goose.GooseProvidersConfigSave({ providerId, fields });
}
export async function deleteProviderConfig(providerId: string): Promise<void> {
return invoke("delete_provider_config", { providerId });
export async function deleteProviderConfig(
providerId: string,
): Promise<ProviderConfigChangeResponse> {
const client = await getClient();
return client.goose.GooseProvidersConfigDelete({ providerId });
}
export async function checkAllProviderStatus(): Promise<ProviderStatus[]> {
return invoke("check_all_provider_status");
}
export async function restartApp(): Promise<void> {
return invoke("restart_app");
const client = await getClient();
const response = await client.goose.GooseProvidersConfigStatus({
providerIds: [],
});
return response.statuses;
}
@@ -0,0 +1,143 @@
import type { ProviderInventoryEntryDto } from "@aaif/goose-sdk";
import { beforeEach, describe, expect, it, vi } from "vitest";
import {
INVENTORY_POLL_DELAYS_MS,
syncProviderInventory,
} from "./inventorySync";
function inventoryEntry(
providerId: string,
refreshing: boolean,
lastRefreshError: string | null = null,
): ProviderInventoryEntryDto {
return {
providerId,
providerName: providerId,
description: "",
defaultModel: "default-model",
configured: true,
providerType: "remote",
configKeys: [],
setupSteps: [],
supportsRefresh: true,
refreshing,
models: [],
lastUpdatedAt: null,
lastRefreshAttemptAt: null,
lastRefreshError,
stale: false,
modelSelectionHint: null,
};
}
describe("syncProviderInventory", () => {
const getInventory = vi.fn();
const refreshInventory = vi.fn();
const onEntries = vi.fn();
const sleep = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
sleep.mockResolvedValue(undefined);
});
it("refreshes and polls with the startup delay schedule until entries settle", async () => {
refreshInventory.mockResolvedValue({
started: ["anthropic"],
skipped: [],
});
getInventory
.mockResolvedValueOnce([inventoryEntry("anthropic", true)])
.mockResolvedValueOnce([inventoryEntry("anthropic", false)]);
const result = await syncProviderInventory(["anthropic"], {
getInventory,
refreshInventory,
onEntries,
sleep,
});
expect(refreshInventory).toHaveBeenCalledWith(["anthropic"]);
expect(getInventory).toHaveBeenNthCalledWith(1, ["anthropic"]);
expect(getInventory).toHaveBeenNthCalledWith(2, ["anthropic"]);
expect(sleep).toHaveBeenCalledTimes(1);
expect(sleep).toHaveBeenCalledWith(INVENTORY_POLL_DELAYS_MS[0]);
expect(onEntries).toHaveBeenCalledTimes(2);
expect(result.settled).toBe(true);
expect(result.entries).toEqual([inventoryEntry("anthropic", false)]);
});
it("uses an initial refresh acknowledgement without starting another refresh", async () => {
const initialRefresh = {
started: ["anthropic"],
skipped: [],
};
getInventory.mockResolvedValueOnce([inventoryEntry("anthropic", false)]);
const result = await syncProviderInventory(["anthropic"], {
getInventory,
refreshInventory,
initialRefresh,
sleep,
});
expect(refreshInventory).not.toHaveBeenCalled();
expect(getInventory).toHaveBeenCalledWith(["anthropic"]);
expect(result.refresh).toBe(initialRefresh);
expect(result.settled).toBe(true);
});
it("polls providers skipped because they were already refreshing", async () => {
refreshInventory.mockResolvedValue({
started: [],
skipped: [
{
providerId: "anthropic",
reason: "already_refreshing",
},
],
});
getInventory
.mockResolvedValueOnce([inventoryEntry("anthropic", true)])
.mockResolvedValueOnce([inventoryEntry("anthropic", false)]);
const result = await syncProviderInventory(["anthropic"], {
getInventory,
refreshInventory,
sleep,
});
expect(result.polledProviderIds).toEqual(["anthropic"]);
expect(getInventory).toHaveBeenNthCalledWith(2, ["anthropic"]);
expect(result.settled).toBe(true);
});
it("merges skipped not-configured entries without forcing a warning state", async () => {
refreshInventory.mockResolvedValue({
started: [],
skipped: [
{
providerId: "anthropic",
reason: "not_configured",
},
],
});
getInventory.mockResolvedValueOnce([
{
...inventoryEntry("anthropic", false),
configured: false,
},
]);
const result = await syncProviderInventory(["anthropic"], {
getInventory,
refreshInventory,
sleep,
});
expect(result.settled).toBe(true);
expect(result.entries[0]?.configured).toBe(false);
expect(result.entries[0]?.lastRefreshError).toBeNull();
expect(sleep).not.toHaveBeenCalled();
});
});
@@ -0,0 +1,115 @@
import type {
ProviderInventoryEntryDto,
RefreshProviderInventoryResponse,
} from "@aaif/goose-sdk";
import { getProviderInventory, refreshProviderInventory } from "./inventory";
export const INVENTORY_POLL_DELAYS_MS = [250, 500, 750, 1000, 1500, 2000];
type GetProviderInventory = typeof getProviderInventory;
type RefreshProviderInventory = typeof refreshProviderInventory;
interface SyncProviderInventoryOptions {
getInventory?: GetProviderInventory;
refreshInventory?: RefreshProviderInventory;
initialRefresh?: RefreshProviderInventoryResponse;
onEntries?: (entries: ProviderInventoryEntryDto[]) => void;
sleep?: (ms: number) => Promise<void>;
}
export interface SyncProviderInventoryResult {
entries: ProviderInventoryEntryDto[];
refresh: RefreshProviderInventoryResponse;
settled: boolean;
polledProviderIds: string[];
}
function defaultSleep(ms: number): Promise<void> {
return new Promise((resolve) => window.setTimeout(resolve, ms));
}
function unique(values: string[]): string[] {
return [...new Set(values.filter(Boolean))];
}
function mergeEntries(
current: Map<string, ProviderInventoryEntryDto>,
entries: ProviderInventoryEntryDto[],
) {
for (const entry of entries) {
current.set(entry.providerId, entry);
}
}
function skippedProviderIds(refresh: RefreshProviderInventoryResponse) {
return (refresh.skipped ?? []).map((skip) => skip.providerId);
}
function alreadyRefreshingProviderIds(
refresh: RefreshProviderInventoryResponse,
) {
return (refresh.skipped ?? [])
.filter((skip) => skip.reason === "already_refreshing")
.map((skip) => skip.providerId);
}
export async function syncProviderInventory(
providerIds: string[],
{
getInventory = getProviderInventory,
refreshInventory = refreshProviderInventory,
initialRefresh,
onEntries,
sleep = defaultSleep,
}: SyncProviderInventoryOptions = {},
): Promise<SyncProviderInventoryResult> {
const refresh = initialRefresh ?? (await refreshInventory(providerIds));
const entriesByProviderId = new Map<string, ProviderInventoryEntryDto>();
const immediateProviderIds = unique([
...providerIds,
...refresh.started,
...skippedProviderIds(refresh),
]);
const immediateEntries = await getInventory(immediateProviderIds);
mergeEntries(entriesByProviderId, immediateEntries);
onEntries?.(immediateEntries);
const polledProviderIds = unique([
...providerIds,
...refresh.started,
...alreadyRefreshingProviderIds(refresh),
]);
if (
polledProviderIds.length === 0 ||
immediateEntries.every((entry) => !entry.refreshing)
) {
return {
entries: [...entriesByProviderId.values()],
refresh,
settled: true,
polledProviderIds,
};
}
for (const delayMs of INVENTORY_POLL_DELAYS_MS) {
await sleep(delayMs);
const entries = await getInventory(polledProviderIds);
mergeEntries(entriesByProviderId, entries);
onEntries?.(entries);
if (entries.every((entry) => !entry.refreshing)) {
return {
entries: [...entriesByProviderId.values()],
refresh,
settled: true,
polledProviderIds,
};
}
}
return {
entries: [...entriesByProviderId.values()],
refresh,
settled: false,
polledProviderIds,
};
}
@@ -0,0 +1,248 @@
import { act, renderHook, waitFor } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { useCredentials } from "./useCredentials";
const mocks = vi.hoisted(() => ({
checkAllProviderStatus: vi.fn(),
deleteProviderConfig: vi.fn(),
getProviderConfig: vi.fn(),
refreshProviderInventory: vi.fn(),
saveProviderConfig: vi.fn(),
syncProviderInventory: vi.fn(),
}));
vi.mock("@/features/providers/api/credentials", () => ({
checkAllProviderStatus: mocks.checkAllProviderStatus,
deleteProviderConfig: mocks.deleteProviderConfig,
getProviderConfig: mocks.getProviderConfig,
saveProviderConfig: mocks.saveProviderConfig,
}));
vi.mock("@/features/providers/api/inventorySync", () => ({
syncProviderInventory: mocks.syncProviderInventory,
}));
vi.mock("@/features/providers/api/inventory", () => ({
refreshProviderInventory: mocks.refreshProviderInventory,
}));
describe("useCredentials", () => {
const saveResponse = {
status: {
providerId: "anthropic",
isConfigured: true,
},
refresh: {
started: ["anthropic"],
skipped: [],
},
};
const deleteResponse = {
status: {
providerId: "anthropic",
isConfigured: false,
},
refresh: {
started: [],
skipped: [
{
providerId: "anthropic",
reason: "not_configured",
},
],
},
};
beforeEach(() => {
vi.clearAllMocks();
mocks.checkAllProviderStatus.mockResolvedValue([
{
providerId: "anthropic",
isConfigured: true,
},
]);
mocks.saveProviderConfig.mockResolvedValue(saveResponse);
mocks.deleteProviderConfig.mockResolvedValue(deleteResponse);
mocks.refreshProviderInventory.mockResolvedValue({
started: ["anthropic"],
skipped: [],
});
mocks.syncProviderInventory.mockResolvedValue({
entries: [],
refresh: {
started: ["anthropic"],
skipped: [],
},
settled: true,
polledProviderIds: ["anthropic"],
});
});
it("saves secret fields through the credential API and syncs inventory without requiring restart", async () => {
const { result } = renderHook(() => useCredentials());
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.save("anthropic", [
{
key: "ANTHROPIC_API_KEY",
value: "sk-ant-test",
isSecret: true,
},
]);
});
const fields = [
{
key: "ANTHROPIC_API_KEY",
value: "sk-ant-test",
},
];
expect(mocks.saveProviderConfig).toHaveBeenCalledWith("anthropic", fields);
await waitFor(() =>
expect(mocks.syncProviderInventory.mock.calls[0]?.[0]).toEqual([
"anthropic",
]),
);
expect(mocks.syncProviderInventory.mock.calls[0]?.[1]).toEqual(
expect.objectContaining({
initialRefresh: saveResponse.refresh,
}),
);
expect(result.current).not.toHaveProperty("needsRestart");
expect(result.current).not.toHaveProperty("restart");
});
it("records refresh failure as a provider warning without rejecting the save", async () => {
mocks.syncProviderInventory.mockRejectedValueOnce(
new Error("model list failed"),
);
const { result } = renderHook(() => useCredentials());
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.save("anthropic", [
{
key: "ANTHROPIC_API_KEY",
value: "sk-ant-test",
isSecret: true,
},
]);
});
expect(mocks.saveProviderConfig).toHaveBeenCalled();
await waitFor(() =>
expect(result.current.inventoryWarnings.get("anthropic")).toContain(
"model list failed",
),
);
});
it("suppresses stale refresh errors after deleting provider config", async () => {
mocks.syncProviderInventory.mockResolvedValueOnce({
entries: [
{
providerId: "anthropic",
lastRefreshError: "old refresh failure",
refreshing: false,
},
],
refresh: deleteResponse.refresh,
settled: true,
polledProviderIds: ["anthropic"],
});
const { result } = renderHook(() => useCredentials());
await waitFor(() => expect(result.current.loading).toBe(false));
await act(async () => {
await result.current.remove("anthropic");
});
await waitFor(() =>
expect(result.current.syncingProviderIds.has("anthropic")).toBe(false),
);
expect(result.current.inventoryWarnings.has("anthropic")).toBe(false);
});
it("invalidates native OAuth secrets before refreshing provider status", async () => {
const refreshResponse = {
started: ["chatgpt_codex"],
skipped: [],
};
mocks.checkAllProviderStatus
.mockResolvedValueOnce([
{
providerId: "chatgpt_codex",
isConfigured: false,
},
])
.mockResolvedValueOnce([
{
providerId: "chatgpt_codex",
isConfigured: true,
},
]);
mocks.refreshProviderInventory.mockResolvedValueOnce(refreshResponse);
const { result } = renderHook(() => useCredentials());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.configuredIds.has("chatgpt_codex")).toBe(false);
await act(async () => {
await result.current.completeNativeSetup("chatgpt_codex");
});
expect(mocks.refreshProviderInventory).toHaveBeenCalledWith([
"chatgpt_codex",
]);
expect(
mocks.refreshProviderInventory.mock.invocationCallOrder[0],
).toBeLessThan(mocks.checkAllProviderStatus.mock.invocationCallOrder[1]);
expect(mocks.syncProviderInventory).toHaveBeenCalledWith(
["chatgpt_codex"],
expect.objectContaining({
initialRefresh: refreshResponse,
}),
);
expect(result.current.configuredIds.has("chatgpt_codex")).toBe(true);
});
it("refreshes native OAuth status when initial inventory refresh fails", async () => {
mocks.checkAllProviderStatus
.mockResolvedValueOnce([
{
providerId: "chatgpt_codex",
isConfigured: false,
},
])
.mockResolvedValueOnce([
{
providerId: "chatgpt_codex",
isConfigured: true,
},
]);
mocks.refreshProviderInventory.mockRejectedValueOnce(
new Error("refresh unavailable"),
);
const { result } = renderHook(() => useCredentials());
await waitFor(() => expect(result.current.loading).toBe(false));
expect(result.current.configuredIds.has("chatgpt_codex")).toBe(false);
await act(async () => {
await result.current.completeNativeSetup("chatgpt_codex");
});
expect(mocks.refreshProviderInventory).toHaveBeenCalledWith([
"chatgpt_codex",
]);
expect(result.current.configuredIds.has("chatgpt_codex")).toBe(true);
expect(mocks.syncProviderInventory).toHaveBeenCalledWith(
["chatgpt_codex"],
expect.objectContaining({
initialRefresh: undefined,
}),
);
});
});
@@ -1,31 +1,81 @@
import { useState, useEffect, useCallback, useMemo } from "react";
import { useState, useEffect, useCallback, useMemo, useRef } from "react";
import {
getProviderConfig,
saveProviderField,
saveProviderConfig,
deleteProviderConfig,
type ProviderStatus,
checkAllProviderStatus,
restartApp,
} from "@/features/providers/api/credentials";
import {
syncProviderInventory,
type SyncProviderInventoryResult,
} from "@/features/providers/api/inventorySync";
import { refreshProviderInventory } from "@/features/providers/api/inventory";
import { useProviderInventoryStore } from "@/features/providers/stores/providerInventoryStore";
import type { ProviderFieldValue } from "@/shared/types/providers";
export interface ProviderFieldSave {
key: string;
value: string;
isSecret: boolean;
}
interface UseCredentialsReturn {
configuredIds: Set<string>;
loading: boolean;
saving: boolean;
needsRestart: boolean;
savingProviderIds: Set<string>;
syncingProviderIds: Set<string>;
inventoryWarnings: Map<string, string>;
getConfig: (providerId: string) => Promise<ProviderFieldValue[]>;
save: (key: string, value: string) => Promise<void>;
save: (providerId: string, fields: ProviderFieldSave[]) => Promise<void>;
remove: (providerId: string) => Promise<void>;
restart: () => Promise<void>;
completeNativeSetup: () => Promise<void>;
completeNativeSetup: (providerId: string) => Promise<void>;
}
function errorMessage(error: unknown): string {
return error instanceof Error ? error.message : String(error);
}
function inventoryWarning(
providerId: string,
result: SyncProviderInventoryResult,
): string | null {
const entry = result.entries.find((item) => item.providerId === providerId);
const skipped = result.refresh.skipped?.find(
(item) => item.providerId === providerId,
);
if (skipped?.reason === "not_configured") {
return null;
}
if (skipped?.reason === "unknown_provider") {
return "Provider inventory is unavailable.";
}
if (entry?.lastRefreshError) {
return entry.lastRefreshError;
}
if (!result.settled && entry?.refreshing) {
return "Model inventory is still refreshing.";
}
return null;
}
export function useCredentials(): UseCredentialsReturn {
const [statuses, setStatuses] = useState<ProviderStatus[]>([]);
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [needsRestart, setNeedsRestart] = useState(false);
const [savingProviderIds, setSavingProviderIds] = useState<Set<string>>(
() => new Set(),
);
const [syncingProviderIds, setSyncingProviderIds] = useState<Set<string>>(
() => new Set(),
);
const [inventoryWarnings, setInventoryWarnings] = useState<
Map<string, string>
>(() => new Map());
const syncRunIds = useRef(new Map<string, number>());
const refreshStatuses = useCallback(async () => {
const nextStatuses = await checkAllProviderStatus();
@@ -33,6 +83,16 @@ export function useCredentials(): UseCredentialsReturn {
return nextStatuses;
}, []);
const updateProviderStatus = useCallback((status: ProviderStatus) => {
setStatuses((current) => {
const next = current.filter(
(item) => item.providerId !== status.providerId,
);
next.push(status);
return next;
});
}, []);
useEffect(() => {
refreshStatuses()
.catch(() => {})
@@ -44,57 +104,158 @@ export function useCredentials(): UseCredentialsReturn {
new Set(statuses.filter((s) => s.isConfigured).map((s) => s.providerId)),
[statuses],
);
const saving = savingProviderIds.size > 0;
const getConfig = useCallback(async (providerId: string) => {
return getProviderConfig(providerId);
}, []);
const setProviderSaving = useCallback(
(providerId: string, isSaving: boolean) => {
setSavingProviderIds((current) => {
const next = new Set(current);
if (isSaving) {
next.add(providerId);
} else {
next.delete(providerId);
}
return next;
});
},
[],
);
const setProviderSyncing = useCallback(
(providerId: string, isSyncing: boolean) => {
setSyncingProviderIds((current) => {
const next = new Set(current);
if (isSyncing) {
next.add(providerId);
} else {
next.delete(providerId);
}
return next;
});
},
[],
);
const setProviderInventoryWarning = useCallback(
(providerId: string, warning: string | null) => {
setInventoryWarnings((current) => {
const next = new Map(current);
if (warning) {
next.set(providerId, warning);
} else {
next.delete(providerId);
}
return next;
});
},
[],
);
const startInventorySync = useCallback(
(
providerId: string,
initialRefresh?: SyncProviderInventoryResult["refresh"],
) => {
const runId = (syncRunIds.current.get(providerId) ?? 0) + 1;
syncRunIds.current.set(providerId, runId);
setProviderSyncing(providerId, true);
setProviderInventoryWarning(providerId, null);
void syncProviderInventory([providerId], {
initialRefresh,
onEntries: (entries) => {
if (syncRunIds.current.get(providerId) !== runId) {
return;
}
useProviderInventoryStore.getState().mergeEntries(entries);
},
})
.then((result) => {
if (syncRunIds.current.get(providerId) !== runId) {
return;
}
setProviderInventoryWarning(
providerId,
inventoryWarning(providerId, result),
);
})
.catch((error) => {
if (syncRunIds.current.get(providerId) !== runId) {
return;
}
setProviderInventoryWarning(providerId, errorMessage(error));
})
.finally(() => {
if (syncRunIds.current.get(providerId) !== runId) {
return;
}
setProviderSyncing(providerId, false);
});
},
[setProviderInventoryWarning, setProviderSyncing],
);
const save = useCallback(
async (key: string, value: string) => {
setSaving(true);
async (providerId: string, fields: ProviderFieldSave[]) => {
setProviderSaving(providerId, true);
try {
await saveProviderField(key, value);
await refreshStatuses();
setNeedsRestart(true);
const result = await saveProviderConfig(
providerId,
fields.map(({ key, value }) => ({ key, value })),
);
updateProviderStatus(result.status);
startInventorySync(providerId, result.refresh);
} finally {
setSaving(false);
setProviderSaving(providerId, false);
}
},
[refreshStatuses],
[setProviderSaving, startInventorySync, updateProviderStatus],
);
const remove = useCallback(
async (providerId: string) => {
setSaving(true);
setProviderSaving(providerId, true);
try {
await deleteProviderConfig(providerId);
await refreshStatuses();
setNeedsRestart(true);
const result = await deleteProviderConfig(providerId);
updateProviderStatus(result.status);
startInventorySync(providerId, result.refresh);
} finally {
setSaving(false);
setProviderSaving(providerId, false);
}
},
[refreshStatuses],
[setProviderSaving, startInventorySync, updateProviderStatus],
);
const restart = useCallback(async () => {
await restartApp();
}, []);
const completeNativeSetup = useCallback(async () => {
await refreshStatuses();
setNeedsRestart(true);
}, [refreshStatuses]);
const completeNativeSetup = useCallback(
async (providerId: string) => {
// Native OAuth returns only after the subprocess writes credentials.
// Inventory refresh invalidates ACP's secret cache before status reads it.
let initialRefresh: SyncProviderInventoryResult["refresh"] | undefined;
try {
initialRefresh = await refreshProviderInventory([providerId]);
} catch (error) {
setProviderInventoryWarning(providerId, errorMessage(error));
}
await refreshStatuses();
startInventorySync(providerId, initialRefresh);
},
[refreshStatuses, setProviderInventoryWarning, startInventorySync],
);
return {
configuredIds,
loading,
saving,
needsRestart,
savingProviderIds,
syncingProviderIds,
inventoryWarnings,
getConfig,
save,
remove,
restart,
completeNativeSetup,
};
}
@@ -24,6 +24,7 @@ interface OutputLine {
}
const MAX_OUTPUT_LINES = 50;
const CHECKING_INDICATOR_DELAY_MS = 2000;
interface AgentProviderCardProps {
provider: ProviderDisplayInfo;
@@ -38,6 +39,7 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
const [setupPhase, setSetupPhase] = useState<SetupPhase>("idle");
const [setupOutput, setSetupOutput] = useState<OutputLine[]>([]);
const [setupError, setSetupError] = useState<string | null>(null);
const [showCheckingIndicator, setShowCheckingIndicator] = useState(false);
const [installStatus, setInstallStatus] = useState<InstallStatus>(
hasBinary && !isBuiltIn ? "checking" : "installed",
);
@@ -228,9 +230,6 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
void handleConnect();
}
if (provider.showOnlyWhenInstalled && installStatus !== "installed")
return null;
const isReady =
isBuiltIn ||
(installStatus === "installed" && !hasAuthCommand) ||
@@ -241,12 +240,34 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
authStatus !== "checking" &&
authStatus !== "authenticated";
const needsInstall = installStatus === "missing" && hasInstallCommand;
const isChecking =
(installStatus === "checking" && hasBinary) ||
(installStatus === "installed" && authStatus === "checking");
useEffect(() => {
if (!isChecking) {
setShowCheckingIndicator(false);
return;
}
setShowCheckingIndicator(false);
const timeoutId = window.setTimeout(() => {
if (isMountedRef.current) {
setShowCheckingIndicator(true);
}
}, CHECKING_INDICATOR_DELAY_MS);
return () => window.clearTimeout(timeoutId);
}, [isChecking]);
if (provider.showOnlyWhenInstalled && installStatus !== "installed")
return null;
function renderStatusIndicator() {
if (isBuiltIn || isReady) {
return (
<div className="flex h-6 flex-shrink-0 items-center">
<IconCheck className="size-4 text-success" />
<IconCheck className="size-4 text-success duration-200 motion-safe:animate-in motion-safe:fade-in" />
</div>
);
}
@@ -259,6 +280,26 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
);
}
if ((isChecking && showCheckingIndicator) || isActive) {
return (
<div
role="status"
aria-label={
isChecking
? t("providers.agents.status.checking")
: t("providers.agents.status.inProgress")
}
className="flex h-6 flex-shrink-0 items-center"
>
<Spinner
role="presentation"
aria-hidden="true"
className="size-4 text-foreground"
/>
</div>
);
}
if (needsAuth && !isActive) {
return (
<Button
@@ -300,11 +341,6 @@ export function AgentProviderCard({ provider }: AgentProviderCardProps) {
if (isBuiltIn || isReady) return null;
if (setupError) return t("providers.agents.status.setupFailed");
if (installStatus === "checking" && hasBinary)
return t("providers.agents.status.checking");
if (installStatus === "installed" && authStatus === "checking")
return t("providers.agents.status.checking");
return null;
}
@@ -16,6 +16,43 @@ import {
renderInlineCodeMessage,
} from "./modelProviderHelpers";
interface InventorySyncMessageProps {
syncing: boolean;
warning?: string | null;
}
export function InventorySyncMessage({
syncing,
warning,
}: InventorySyncMessageProps) {
const { t } = useTranslation("settings");
if (syncing) {
return (
<p
role="status"
className="flex items-center gap-2 text-xs text-muted-foreground"
>
<IconLoader2 className="size-3 animate-spin text-accent" />
<span>{t("providers.loadingModels")}</span>
</p>
);
}
if (warning) {
return (
<p
role="status"
className="rounded-md border border-border-warning bg-background-warning/20 px-2.5 py-2 text-xs text-text-warning"
>
{t("providers.modelRefreshWarning", { message: warning })}
</p>
);
}
return null;
}
interface ConnectedFieldsPanelProps {
panelRef: RefObject<HTMLDivElement | null>;
fields: ProviderField[];
@@ -23,6 +60,8 @@ interface ConnectedFieldsPanelProps {
editingKey: string | null;
draftValues: Record<string, string>;
saving: boolean;
inventorySyncing: boolean;
inventoryWarning?: string | null;
showSavedState: boolean;
error: string;
setupMessage: string | null;
@@ -40,6 +79,8 @@ export function ConnectedFieldsPanel({
editingKey,
draftValues,
saving,
inventorySyncing,
inventoryWarning,
showSavedState,
error,
setupMessage,
@@ -158,6 +199,10 @@ export function ConnectedFieldsPanel({
</Button>
</div>
{renderSetupMessage(setupMessage)}
<InventorySyncMessage
syncing={inventorySyncing}
warning={inventoryWarning}
/>
{error && <p className="text-xs text-danger">{error}</p>}
</div>
);
@@ -169,6 +214,8 @@ interface SetupFieldsPanelProps {
fieldValueMap: Map<string, ProviderFieldValue>;
draftValues: Record<string, string>;
saving: boolean;
inventorySyncing: boolean;
inventoryWarning?: string | null;
showSavedState: boolean;
error: string;
setupMethod: ProviderSetupMethod;
@@ -185,6 +232,8 @@ export function SetupFieldsPanel({
fieldValueMap,
draftValues,
saving,
inventorySyncing,
inventoryWarning,
showSavedState,
error,
setupMethod,
@@ -257,6 +306,10 @@ export function SetupFieldsPanel({
{setupMethod === "cloud_credentials" && setupMessage
? renderSetupMessage(setupMessage)
: null}
<InventorySyncMessage
syncing={inventorySyncing}
warning={inventoryWarning}
/>
{error && <p className="text-xs text-danger">{error}</p>}
</div>
);
@@ -34,24 +34,38 @@ import {
getFieldSetupDescription,
renderSetupMessage,
} from "./modelProviderHelpers";
import { ConnectedFieldsPanel, SetupFieldsPanel } from "./ModelProviderPanels";
import {
ConnectedFieldsPanel,
InventorySyncMessage,
SetupFieldsPanel,
} from "./ModelProviderPanels";
interface ProviderFieldSaveInput {
key: string;
value: string;
isSecret: boolean;
}
interface ModelProviderRowProps {
provider: ProviderDisplayInfo;
onGetConfig: (providerId: string) => Promise<ProviderFieldValue[]>;
onSaveField: (key: string, value: string, isSecret: boolean) => Promise<void>;
onSaveFields: (fields: ProviderFieldSaveInput[]) => Promise<void>;
onRemoveConfig?: () => Promise<void>;
onCompleteNativeSetup: () => Promise<void>;
onCompleteNativeSetup: (providerId: string) => Promise<void>;
saving?: boolean;
inventorySyncing?: boolean;
inventoryWarning?: string | null;
}
export function ModelProviderRow({
provider,
onGetConfig,
onSaveField,
onSaveFields,
onRemoveConfig,
onCompleteNativeSetup,
saving = false,
inventorySyncing = false,
inventoryWarning = null,
}: ModelProviderRowProps) {
const { t } = useTranslation("settings");
const [expanded, setExpanded] = useState(false);
@@ -161,8 +175,10 @@ export function ModelProviderRow({
const unlisten = await onModelSetupOutput(provider.id, appendSetupOutput);
try {
// The native connector exits after writing credentials; only then do we
// ask the credentials hook to refresh ACP inventory for this provider.
await authenticateModelProvider(provider.id, provider.nativeConnectQuery);
await onCompleteNativeSetup();
await onCompleteNativeSetup(provider.id);
} catch (nextError) {
setSetupError(
nextError instanceof Error
@@ -219,7 +235,9 @@ export function ModelProviderRow({
setError("");
try {
shouldRestorePanelFocus.current = true;
await onSaveField(field.key, nextValue, field.secret);
await onSaveFields([
{ key: field.key, value: nextValue, isSecret: field.secret },
]);
await loadConfig();
setEditingKey(null);
setShowSavedState(true);
@@ -269,10 +287,13 @@ export function ModelProviderRow({
setError("");
try {
for (const field of fieldsToSave) {
const nextValue = draftValues[field.key]?.trim() ?? "";
await onSaveField(field.key, nextValue, field.secret);
}
await onSaveFields(
fieldsToSave.map((field) => ({
key: field.key,
value: draftValues[field.key]?.trim() ?? "",
isSecret: field.secret,
})),
);
await loadConfig();
setShowSavedState(true);
setPreserveSetupLayout(true);
@@ -364,6 +385,10 @@ export function ModelProviderRow({
<span>{t("providers.waitingForSignIn")}</span>
</div>
) : null}
<InventorySyncMessage
syncing={inventorySyncing}
warning={inventoryWarning}
/>
{setupOutput.length > 0 ? (
<div className="space-y-1 rounded-md bg-muted px-3 py-2 font-mono text-xxs text-muted-foreground">
{setupOutput.map((line) => (
@@ -387,6 +412,8 @@ export function ModelProviderRow({
editingKey={editingKey}
draftValues={draftValues}
saving={saving}
inventorySyncing={inventorySyncing}
inventoryWarning={inventoryWarning}
showSavedState={showSavedState}
error={error}
setupMessage={setupMessage}
@@ -407,6 +434,8 @@ export function ModelProviderRow({
fieldValueMap={fieldValueMap}
draftValues={draftValues}
saving={saving}
inventorySyncing={inventorySyncing}
inventoryWarning={inventoryWarning}
showSavedState={showSavedState}
error={error}
setupMethod={provider.setupMethod}
@@ -426,6 +455,10 @@ export function ModelProviderRow({
className="focus-override mx-3 space-y-2 rounded-b-lg border-x border-b px-3 py-3 outline-none"
>
{renderSetupMessage(setupMessage)}
<InventorySyncMessage
syncing={inventorySyncing}
warning={inventoryWarning}
/>
</div>
);
}
@@ -452,6 +485,9 @@ export function ModelProviderRow({
{isConnected ? (
<IconCheck className="size-4 flex-shrink-0 text-success" />
) : null}
{inventorySyncing ? (
<Spinner className="size-3.5 flex-shrink-0 text-accent" />
) : null}
{!isConnected && authenticating ? (
<Spinner className="size-3.5 flex-shrink-0 text-accent" />
) : null}
@@ -2,8 +2,8 @@ import { useEffect, useMemo, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/shared/ui/button";
import { Separator } from "@/shared/ui/separator";
import { Skeleton } from "@/shared/ui/skeleton";
import { IconChevronDown, IconRefresh } from "@tabler/icons-react";
import { Spinner } from "@/shared/ui/spinner";
import { IconChevronDown } from "@tabler/icons-react";
import {
getAgentProviders,
getModelProviders,
@@ -45,12 +45,12 @@ export function ProvidersSettings() {
const {
configuredIds,
loading,
saving,
needsRestart,
savingProviderIds,
syncingProviderIds,
inventoryWarnings,
getConfig,
save,
remove,
restart,
completeNativeSetup,
} = useCredentials();
@@ -112,23 +112,6 @@ export function ProvidersSettings() {
const advancedModels = orderedModels.filter((m) => m.tier === "advanced");
const visibleModels = showAllModels ? orderedModels : promotedModels;
if (loading) {
return (
<div className="space-y-6">
<div>
<Skeleton className="h-5 w-32" />
<Skeleton className="mt-2 h-4 w-64" />
</div>
<div className="grid grid-cols-2 gap-3">
<Skeleton className="h-28" />
<Skeleton className="h-28" />
<Skeleton className="h-28" />
<Skeleton className="h-28" />
</div>
</div>
);
}
return (
<div>
<h3 className="text-lg font-semibold font-display tracking-tight">
@@ -138,16 +121,6 @@ export function ProvidersSettings() {
{t("providers.description")}
</p>
{needsRestart && (
<div className="mt-3 flex items-center gap-3 rounded-lg border border-accent bg-background-accent/30 px-3 py-2.5">
<p className="flex-1 text-sm">{t("providers.restartMessage")}</p>
<Button type="button" size="sm" onClick={() => void restart()}>
<IconRefresh className="size-3.5" />
{t("providers.restartButton")}
</Button>
</div>
)}
<Separator className="my-4" />
<section>
@@ -171,9 +144,17 @@ export function ProvidersSettings() {
<section>
<div className="mb-3">
<h4 className="text-sm font-semibold">
{t("providers.models.title")}
</h4>
<div className="flex items-center gap-2">
<h4 className="text-sm font-semibold">
{t("providers.models.title")}
</h4>
{loading ? (
<span className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<Spinner className="size-3 text-accent" />
{t("providers.models.checkingStatus")}
</span>
) : null}
</div>
<p className="mt-0.5 text-xs text-muted-foreground">
{t("providers.models.description")}
</p>
@@ -185,10 +166,12 @@ export function ProvidersSettings() {
key={model.id}
provider={model}
onGetConfig={getConfig}
onSaveField={save}
onSaveFields={(fields) => save(model.id, fields)}
onRemoveConfig={() => remove(model.id)}
onCompleteNativeSetup={completeNativeSetup}
saving={saving}
saving={savingProviderIds.has(model.id)}
inventorySyncing={syncingProviderIds.has(model.id)}
inventoryWarning={inventoryWarnings.get(model.id)}
/>
))}
</div>
@@ -1,6 +1,6 @@
import { act, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import { renderWithProviders } from "@/test/render";
import { AgentProviderCard } from "../AgentProviderCard";
import type { ProviderDisplayInfo } from "@/shared/types/providers";
@@ -37,7 +37,12 @@ describe("AgentProviderCard", () => {
vi.clearAllMocks();
});
it("does not show sign in while auth status is checking", async () => {
afterEach(() => {
vi.useRealTimers();
});
it("delays the checking indicator and does not show sign in while auth status is checking", async () => {
vi.useFakeTimers();
let resolveAuth!: (authenticated: boolean) => void;
const authPromise = new Promise<boolean>((resolve) => {
resolveAuth = resolve;
@@ -48,21 +53,53 @@ describe("AgentProviderCard", () => {
renderWithProviders(<AgentProviderCard provider={createProvider()} />);
expect(await screen.findByText("Checking...")).toBeInTheDocument();
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(checkAgentAuth).toHaveBeenCalled();
expect(screen.queryByRole("status", { name: "Checking..." })).toBeNull();
expect(screen.queryByText("Checking...")).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /sign in/i }),
).not.toBeInTheDocument();
act(() => {
vi.advanceTimersByTime(2000);
});
expect(
screen.getByRole("status", { name: "Checking..." }),
).toBeInTheDocument();
await act(async () => {
resolveAuth(false);
await authPromise;
});
await waitFor(() => {
expect(
screen.getByRole("button", { name: /sign in/i }),
).toBeInTheDocument();
expect(
screen.getByRole("button", { name: /sign in/i }),
).toBeInTheDocument();
});
it("does not show the checking indicator when auth resolves quickly", async () => {
vi.useFakeTimers();
checkAgentInstalled.mockResolvedValue(true);
checkAgentAuth.mockResolvedValue(true);
renderWithProviders(<AgentProviderCard provider={createProvider()} />);
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
act(() => {
vi.advanceTimersByTime(2000);
});
expect(screen.queryByRole("status", { name: "Checking..." })).toBeNull();
});
it("checks installation by provider id after installing", async () => {
@@ -0,0 +1,113 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import type { ComponentType } from "react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { getModelProviders } from "@/features/providers/providerCatalog";
import { ModelProviderRow } from "../ModelProviderRow";
const Row = ModelProviderRow as unknown as ComponentType<
Record<string, unknown>
>;
function modelProvider(id: string, status: "connected" | "not_configured") {
const provider = getModelProviders().find((entry) => entry.id === id);
if (!provider) {
throw new Error(`missing provider fixture: ${id}`);
}
return {
...provider,
status,
};
}
describe("ModelProviderRow", () => {
const onGetConfig = vi.fn();
const onSaveFields = vi.fn();
const onRemoveConfig = vi.fn();
const onCompleteNativeSetup = vi.fn();
beforeEach(() => {
vi.clearAllMocks();
onGetConfig.mockResolvedValue([]);
onSaveFields.mockResolvedValue(undefined);
onRemoveConfig.mockResolvedValue(undefined);
onCompleteNativeSetup.mockResolvedValue(undefined);
});
it("saves all changed setup fields from one setup submit", async () => {
const user = userEvent.setup();
render(
<ModelProviderRow
provider={modelProvider("databricks", "not_configured")}
onGetConfig={onGetConfig}
onSaveFields={onSaveFields}
onRemoveConfig={onRemoveConfig}
onCompleteNativeSetup={onCompleteNativeSetup}
/>,
);
await user.click(screen.getByRole("button", { name: /databricks/i }));
await user.type(
await screen.findByPlaceholderText(/cloud\.databricks\.com/i),
"https://dbc-test.cloud.databricks.com",
);
await user.type(
screen.getByPlaceholderText(/paste your access token/i),
"databricks-token",
);
await user.click(screen.getByRole("button", { name: /^save$/i }));
await waitFor(() => expect(onSaveFields).toHaveBeenCalledTimes(1));
expect(onSaveFields).toHaveBeenCalledWith([
{
key: "DATABRICKS_HOST",
value: "https://dbc-test.cloud.databricks.com",
isSecret: false,
},
{
key: "DATABRICKS_TOKEN",
value: "databricks-token",
isSecret: true,
},
]);
});
it("shows the connected row while model inventory is still loading", async () => {
const user = userEvent.setup();
render(
<Row
provider={modelProvider("anthropic", "connected")}
onGetConfig={onGetConfig}
onSaveFields={onSaveFields}
onRemoveConfig={onRemoveConfig}
onCompleteNativeSetup={onCompleteNativeSetup}
inventorySyncing={true}
/>,
);
await user.click(screen.getByRole("button", { name: /anthropic/i }));
expect(screen.getByText(/loading models/i)).toBeInTheDocument();
});
it("shows a non-blocking inventory warning without replacing the connected state", async () => {
const user = userEvent.setup();
render(
<Row
provider={modelProvider("anthropic", "connected")}
onGetConfig={onGetConfig}
onSaveFields={onSaveFields}
onRemoveConfig={onRemoveConfig}
onCompleteNativeSetup={onCompleteNativeSetup}
inventoryWarning="Model refresh failed"
/>,
);
await user.click(screen.getByRole("button", { name: /anthropic/i }));
expect(screen.getByText(/model refresh failed/i)).toBeInTheDocument();
});
});
@@ -0,0 +1,91 @@
import { render, screen } from "@testing-library/react";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { ProvidersSettings } from "../ProvidersSettings";
const mocks = vi.hoisted(() => ({
useCredentials: vi.fn(),
}));
vi.mock("@/features/providers/hooks/useCredentials", () => ({
useCredentials: () => mocks.useCredentials(),
}));
describe("ProvidersSettings", () => {
beforeEach(() => {
vi.clearAllMocks();
mocks.useCredentials.mockReturnValue({
configuredIds: new Set<string>(),
loading: false,
saving: false,
savingProviderIds: new Set<string>(),
syncingProviderIds: new Set<string>(),
inventoryWarnings: new Map<string, string>(),
getConfig: vi.fn(),
save: vi.fn(),
remove: vi.fn(),
completeNativeSetup: vi.fn(),
});
});
it("does not show the restart banner for provider credential changes", () => {
render(<ProvidersSettings />);
expect(
screen.queryByText(/restart to apply credential changes/i),
).not.toBeInTheDocument();
expect(
screen.queryByRole("button", { name: /restart now/i }),
).not.toBeInTheDocument();
});
it("renders the static provider catalog while credential status is loading", () => {
mocks.useCredentials.mockReturnValue({
configuredIds: new Set<string>(),
loading: true,
saving: false,
savingProviderIds: new Set<string>(),
syncingProviderIds: new Set<string>(),
inventoryWarnings: new Map<string, string>(),
getConfig: vi.fn(),
save: vi.fn(),
remove: vi.fn(),
completeNativeSetup: vi.fn(),
});
render(<ProvidersSettings />);
expect(screen.getByText("Providers")).toBeInTheDocument();
expect(screen.getByText("Anthropic")).toBeInTheDocument();
expect(screen.getByText("Checking provider status...")).toBeInTheDocument();
});
it("matches main by ordering connected model providers first after status loads", () => {
mocks.useCredentials.mockReturnValue({
configuredIds: new Set<string>(["openai", "databricks"]),
loading: false,
saving: false,
savingProviderIds: new Set<string>(),
syncingProviderIds: new Set<string>(),
inventoryWarnings: new Map<string, string>(),
getConfig: vi.fn(),
save: vi.fn(),
remove: vi.fn(),
completeNativeSetup: vi.fn(),
});
render(<ProvidersSettings />);
const openai = screen.getByText("OpenAI");
const databricks = screen.getByText("Databricks");
const anthropic = screen.getByText("Anthropic");
expect(
openai.compareDocumentPosition(databricks) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
expect(
databricks.compareDocumentPosition(anthropic) &
Node.DOCUMENT_POSITION_FOLLOWING,
).toBeTruthy();
});
});
@@ -2,11 +2,13 @@ import { describe, it, expect, vi, beforeEach } from "vitest";
import {
cancelDictationLocalModelDownload,
deleteDictationLocalModel,
deleteDictationProviderSecret,
downloadDictationLocalModel,
getDictationConfig,
getDictationLocalModelDownloadProgress,
listDictationLocalModels,
saveDictationModelSelection,
saveDictationProviderSecret,
transcribeDictation,
} from "../dictation";
import { getClient } from "../acpConnection";
@@ -67,6 +69,23 @@ describe("dictation SDK wiring", () => {
});
});
it("saveDictationProviderSecret calls GooseSecretUpsert", async () => {
client.goose.GooseSecretUpsert = vi.fn().mockResolvedValue({});
await saveDictationProviderSecret("groq", "gsk-test", "GROQ_API_KEY");
expect(client.goose.GooseSecretUpsert).toHaveBeenCalledWith({
key: "GROQ_API_KEY",
value: "gsk-test",
});
});
it("deleteDictationProviderSecret calls GooseSecretRemove", async () => {
client.goose.GooseSecretRemove = vi.fn().mockResolvedValue({});
await deleteDictationProviderSecret("groq", "GROQ_API_KEY");
expect(client.goose.GooseSecretRemove).toHaveBeenCalledWith({
key: "GROQ_API_KEY",
});
});
it("listDictationLocalModels returns the models array", async () => {
client.goose.GooseDictationModelsList = vi.fn().mockResolvedValue({
models: [
+7 -11
View File
@@ -1,4 +1,3 @@
import { invoke } from "@tauri-apps/api/core";
import type {
DictationDownloadProgress,
DictationProvider,
@@ -48,22 +47,19 @@ export async function saveDictationProviderSecret(
if (!configKey) {
throw new Error("No config key for this provider");
}
return invoke("save_provider_field", { key: configKey, value });
const client = await getClient();
await client.goose.GooseSecretUpsert({ key: configKey, value });
}
export async function deleteDictationProviderSecret(
provider: DictationProvider,
_configKey?: string,
_provider: DictationProvider,
configKey?: string,
): Promise<void> {
const providerIdMap: Record<string, string> = {
groq: "dictation_groq",
elevenlabs: "dictation_elevenlabs",
};
const providerId = providerIdMap[provider];
if (!providerId) {
if (!configKey) {
throw new Error("Cannot delete secrets for this provider");
}
return invoke("delete_provider_config", { providerId });
const client = await getClient();
await client.goose.GooseSecretRemove({ key: configKey });
}
export async function listDictationLocalModels(): Promise<
@@ -220,9 +220,10 @@
"signInLabel": "Sign in to {{name}}",
"status": {
"checking": "Checking...",
"inProgress": "Setup in progress",
"setupFailed": "Setup failed"
},
"title": "Agents"
"title": "Agent harnesses"
},
"description": "Connect agents and AI models to use with Goose",
"disconnect": "Disconnect",
@@ -251,10 +252,11 @@
"oauthTerminal": "Run `goose configure` in your terminal to finish sign-in."
}
},
"checkingStatus": "Checking provider status...",
"title": "Models"
},
"restartButton": "Restart now",
"restartMessage": "Restart to apply credential changes.",
"loadingModels": "Loading models...",
"modelRefreshWarning": "Credentials saved. Model refresh needs attention: {{message}}",
"saved": "Saved",
"showFewer": "Show fewer",
"showMore": "Show {{count}} more providers",
@@ -220,9 +220,10 @@
"signInLabel": "Iniciar sesión en {{name}}",
"status": {
"checking": "Comprobando...",
"inProgress": "Configuración en curso",
"setupFailed": "La configuración falló"
},
"title": "Agentes"
"title": "Arneses de agentes"
},
"description": "Conecta agentes y modelos de IA para usar con Goose",
"disconnect": "Desconectar",
@@ -251,10 +252,11 @@
"oauthTerminal": "Ejecuta `goose configure` en tu terminal para terminar de iniciar sesión."
}
},
"checkingStatus": "Comprobando estado del proveedor...",
"title": "Modelos"
},
"restartButton": "Reiniciar ahora",
"restartMessage": "Reinicia para aplicar los cambios de credenciales.",
"loadingModels": "Cargando modelos...",
"modelRefreshWarning": "Credenciales guardadas. La actualización de modelos necesita atención: {{message}}",
"saved": "Guardado",
"showFewer": "Mostrar menos",
"showMore": "Mostrar {{count}} proveedores más",