feat(acp): replace raw config and secret methods (#9000)

Signed-off-by: Kalvin Chau <kalvin@block.xyz>
This commit is contained in:
Kalvin C
2026-05-04 18:24:28 -07:00
committed by GitHub
parent e82c4fdcb7
commit 2fe4c3d0bb
39 changed files with 1481 additions and 855 deletions
+8
View File
@@ -169,6 +169,14 @@ The skills → sources migration in [#8675](https://github.com/block/goose/pull/
For a minimal frontend `api/` wrapper using the typed shape, see `ui/goose2/src/features/providers/api/inventory.ts` — ~30 lines, typed SDK calls, thin adapter. For a fully worked end-to-end feature including OS-keychain handling and progress streaming, see the voice dictation feature ([#8609](https://github.com/block/goose/pull/8609)) and `ui/goose2/src/shared/api/dictation.ts`.
### Typed ACP config contracts
Build goose2 config flows around typed ACP methods whose contract matches the domain: provider config, preferences, defaults, dictation secrets, or extension config.
Keep raw Goose storage keys and secret-handling decisions behind backend-owned ACP methods. This lets Goose validate inputs, apply provider metadata, invalidate caches, refresh dependent state, and keep generated SDK types aligned with supported behavior.
For reference, define contracts in `crates/goose-sdk/src/custom_requests.rs`, implement them in `crates/goose/src/acp/server/`, regenerate `ui/sdk/src/generated/`, then call the generated `client.goose.*` methods from a feature or shared `api/` wrapper.
### When `invoke()` is still appropriate
Tauri commands (`invoke()` from `@tauri-apps/api/core`) are reserved for things that genuinely belong to the desktop shell, not to `goose` core. Provider config or secret mutations that affect the Goose runtime must flow through React → SDK → ACP → goose core so core can validate provider metadata, invalidate secret caches, refresh inventory, and apply provider changes consistently. In practice, Tauri is limited to:
@@ -2,7 +2,6 @@ import { act, renderHook, waitFor } from "@testing-library/react";
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
import {
AUTO_COMPACT_PREFERENCES_EVENT,
AUTO_COMPACT_THRESHOLD_CONFIG_KEY,
DEFAULT_AUTO_COMPACT_THRESHOLD,
} from "../../lib/autoCompact";
@@ -26,8 +25,10 @@ describe("useAutoCompactPreferences", () => {
it("hydrates from the stored threshold value", async () => {
mockGetClient.mockResolvedValue({
goose: {
GooseConfigRead: vi.fn().mockResolvedValue({ value: 0.65 }),
GooseConfigUpsert: vi.fn().mockResolvedValue({}),
GoosePreferencesRead: vi.fn().mockResolvedValue({
values: [{ key: "autoCompactThreshold", value: 0.65 }],
}),
GoosePreferencesSave: vi.fn().mockResolvedValue({}),
},
});
@@ -42,13 +43,17 @@ describe("useAutoCompactPreferences", () => {
const upsert = vi.fn().mockResolvedValue({});
const read = vi
.fn()
.mockResolvedValueOnce({ value: null })
.mockResolvedValue({ value: 0.9 });
.mockResolvedValueOnce({
values: [{ key: "autoCompactThreshold", value: null }],
})
.mockResolvedValue({
values: [{ key: "autoCompactThreshold", value: 0.9 }],
});
mockGetClient.mockResolvedValue({
goose: {
GooseConfigRead: read,
GooseConfigUpsert: upsert,
GoosePreferencesRead: read,
GoosePreferencesSave: upsert,
},
});
@@ -64,8 +69,7 @@ describe("useAutoCompactPreferences", () => {
});
expect(upsert).toHaveBeenCalledWith({
key: AUTO_COMPACT_THRESHOLD_CONFIG_KEY,
value: 0.9,
values: [{ key: "autoCompactThreshold", value: 0.9 }],
});
expect(eventListener).toHaveBeenCalledTimes(1);
expect(result.current.autoCompactThreshold).toBe(0.9);
@@ -73,13 +77,17 @@ describe("useAutoCompactPreferences", () => {
window.removeEventListener(AUTO_COMPACT_PREFERENCES_EVENT, eventListener);
});
it("marks the preferences hydrated even when the initial read fails", async () => {
it("does not mark preferences hydrated when the initial read fails", async () => {
mockGetClient.mockRejectedValue(new Error("ACP not ready"));
const { result } = renderHook(() => useAutoCompactPreferences());
await waitFor(() => expect(result.current.isHydrated).toBe(true));
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(result.current.isHydrated).toBe(false);
expect(result.current.autoCompactThreshold).toBe(
DEFAULT_AUTO_COMPACT_THRESHOLD,
);
@@ -90,12 +98,14 @@ describe("useAutoCompactPreferences", () => {
const read = vi
.fn()
.mockRejectedValueOnce(new Error("ACP not ready"))
.mockResolvedValueOnce({ value: 0.65 });
.mockResolvedValueOnce({
values: [{ key: "autoCompactThreshold", value: 0.65 }],
});
mockGetClient.mockResolvedValue({
goose: {
GooseConfigRead: read,
GooseConfigUpsert: vi.fn().mockResolvedValue({}),
GoosePreferencesRead: read,
GoosePreferencesSave: vi.fn().mockResolvedValue({}),
},
});
@@ -106,7 +116,7 @@ describe("useAutoCompactPreferences", () => {
await Promise.resolve();
});
expect(result.current.isHydrated).toBe(true);
expect(result.current.isHydrated).toBe(false);
expect(result.current.autoCompactThreshold).toBe(
DEFAULT_AUTO_COMPACT_THRESHOLD,
);
@@ -116,6 +126,47 @@ describe("useAutoCompactPreferences", () => {
});
expect(result.current.autoCompactThreshold).toBe(0.65);
expect(result.current.isHydrated).toBe(true);
expect(read).toHaveBeenCalledTimes(2);
});
it("backs off repeated hydration retries", async () => {
vi.useFakeTimers();
const read = vi.fn().mockRejectedValue(new Error("ACP not ready"));
mockGetClient.mockResolvedValue({
goose: {
GoosePreferencesRead: read,
GoosePreferencesSave: vi.fn().mockResolvedValue({}),
},
});
renderHook(() => useAutoCompactPreferences());
await act(async () => {
await Promise.resolve();
await Promise.resolve();
});
expect(read).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(999);
});
expect(read).toHaveBeenCalledTimes(1);
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(read).toHaveBeenCalledTimes(2);
await act(async () => {
await vi.advanceTimersByTimeAsync(1999);
});
expect(read).toHaveBeenCalledTimes(2);
await act(async () => {
await vi.advanceTimersByTimeAsync(1);
});
expect(read).toHaveBeenCalledTimes(3);
});
});
@@ -9,7 +9,7 @@ const mockAcpPrepareSession = vi.fn();
const mockAcpSetModel = vi.fn();
const mockSetSelectedProvider = vi.fn();
const mockResolveSessionCwd = vi.fn();
const mockGooseConfigRead = vi.fn();
const mockGooseDefaultsRead = vi.fn();
const mockUseProviderInventory = vi.fn();
const mockPickerState = {
pickerAgents: [{ id: "goose", label: "Goose" }],
@@ -31,7 +31,7 @@ vi.mock("@/shared/api/acp", () => ({
vi.mock("@/shared/api/acpConnection", () => ({
getClient: async () => ({
goose: {
GooseConfigRead: (...args: unknown[]) => mockGooseConfigRead(...args),
GooseDefaultsRead: (...args: unknown[]) => mockGooseDefaultsRead(...args),
},
}),
}));
@@ -116,7 +116,10 @@ describe("useChatSessionController", () => {
mockAcpPrepareSession.mockResolvedValue(undefined);
mockAcpSetModel.mockResolvedValue(undefined);
mockResolveSessionCwd.mockResolvedValue("/tmp/project");
mockGooseConfigRead.mockResolvedValue({ value: null });
mockGooseDefaultsRead.mockResolvedValue({
providerId: null,
modelId: null,
});
mockUseProviderInventory.mockReturnValue({
getEntry: () => undefined,
});
@@ -283,17 +286,10 @@ describe("useChatSessionController", () => {
it("falls back to the configured goose default model when no explicit model is stored", async () => {
useAgentStore.setState({ selectedProvider: "goose" });
mockGooseConfigRead.mockImplementation(
async ({ key }: { key: string }): Promise<{ value: string | null }> => {
if (key === "GOOSE_PROVIDER") {
return { value: "databricks" };
}
if (key === "GOOSE_MODEL") {
return { value: "goose-claude-4-6-opus" };
}
return { value: null };
},
);
mockGooseDefaultsRead.mockResolvedValue({
providerId: "databricks",
modelId: "goose-claude-4-6-opus",
});
mockPickerState.availableModels = [
{
id: "goose-claude-4-6-opus",
@@ -26,7 +26,10 @@ describe("useResolvedAgentModelPicker", () => {
mockGetClient.mockResolvedValue({
goose: {
GooseConfigRead: vi.fn().mockResolvedValue({ value: null }),
GooseDefaultsRead: vi.fn().mockResolvedValue({
providerId: null,
modelId: null,
}),
},
});
@@ -27,17 +27,20 @@ describe("useVoiceInputPreferences", () => {
mockGetClient.mockResolvedValue({
goose: {
GooseConfigRead: vi.fn().mockImplementation(({ key }) => {
if (key === "VOICE_DICTATION_PROVIDER") {
if (shouldFailProviderRead) {
return Promise.reject(new Error("temporary acp failure"));
}
return Promise.resolve({ value: "groq" });
GoosePreferencesRead: vi.fn().mockImplementation(() => {
if (shouldFailProviderRead) {
return Promise.reject(new Error("temporary acp failure"));
}
return Promise.resolve({ value: null });
return Promise.resolve({
values: [
{ key: "voiceAutoSubmitPhrases", value: null },
{ key: "voiceDictationProvider", value: "groq" },
{ key: "voiceDictationPreferredMic", value: null },
],
});
}),
GooseConfigUpsert: vi.fn().mockResolvedValue({}),
GooseConfigRemove: vi.fn().mockResolvedValue({}),
GoosePreferencesSave: vi.fn().mockResolvedValue({}),
GoosePreferencesRemove: vi.fn().mockResolvedValue({}),
},
});
@@ -61,21 +64,27 @@ describe("useVoiceInputPreferences", () => {
it("broadcasts preference changes only after config persistence settles", async () => {
const upsert = vi.fn();
const providerRead = deferred<{ value?: unknown }>();
const providerRead = deferred<{
values: Array<{ key: string; value: unknown }>;
}>();
const pendingWrite = deferred<void>();
mockGetClient.mockResolvedValue({
goose: {
GooseConfigRead: vi
GoosePreferencesRead: vi
.fn()
.mockResolvedValueOnce({ value: null })
.mockResolvedValueOnce({ value: null })
.mockResolvedValueOnce({ value: null })
.mockResolvedValueOnce({
values: [
{ key: "voiceAutoSubmitPhrases", value: null },
{ key: "voiceDictationProvider", value: null },
{ key: "voiceDictationPreferredMic", value: null },
],
})
.mockImplementation(() => providerRead.promise),
GooseConfigUpsert: upsert.mockImplementation(
GoosePreferencesSave: upsert.mockImplementation(
() => pendingWrite.promise,
),
GooseConfigRemove: vi.fn().mockResolvedValue({}),
GoosePreferencesRemove: vi.fn().mockResolvedValue({}),
},
});
@@ -100,7 +109,114 @@ describe("useVoiceInputPreferences", () => {
await waitFor(() => expect(eventListener).toHaveBeenCalledTimes(1));
providerRead.resolve({ value: "openai" });
providerRead.resolve({
values: [
{ key: "voiceAutoSubmitPhrases", value: null },
{ key: "voiceDictationProvider", value: "openai" },
{ key: "voiceDictationPreferredMic", value: null },
],
});
window.removeEventListener("goose:voice-input-preferences", eventListener);
});
it("does not broadcast failed preference writes and re-syncs stored state", async () => {
const warn = vi.spyOn(console, "warn").mockImplementation(() => {});
const save = vi.fn().mockRejectedValue(new Error("write failed"));
const read = vi.fn().mockResolvedValue({
values: [
{ key: "voiceAutoSubmitPhrases", value: null },
{ key: "voiceDictationProvider", value: "groq" },
{ key: "voiceDictationPreferredMic", value: null },
],
});
mockGetClient.mockResolvedValue({
goose: {
GoosePreferencesRead: read,
GoosePreferencesSave: save,
GoosePreferencesRemove: vi.fn().mockResolvedValue({}),
},
});
const eventListener = vi.fn();
window.addEventListener("goose:voice-input-preferences", eventListener);
const { result } = renderHook(() => useVoiceInputPreferences());
await waitFor(() => expect(result.current.isHydrated).toBe(true));
act(() => {
result.current.setSelectedProvider("openai");
});
expect(result.current.selectedProvider).toBe("openai");
await waitFor(() => expect(read).toHaveBeenCalledTimes(2));
expect(eventListener).not.toHaveBeenCalled();
expect(result.current.selectedProvider).toBe("groq");
window.removeEventListener("goose:voice-input-preferences", eventListener);
warn.mockRestore();
});
it("stores the disabled sentinel when provider is set to null", async () => {
const save = vi.fn().mockResolvedValue({});
mockGetClient.mockResolvedValue({
goose: {
GoosePreferencesRead: vi.fn().mockResolvedValue({
values: [
{ key: "voiceAutoSubmitPhrases", value: null },
{ key: "voiceDictationProvider", value: "groq" },
{ key: "voiceDictationPreferredMic", value: null },
],
}),
GoosePreferencesSave: save,
GoosePreferencesRemove: vi.fn().mockResolvedValue({}),
},
});
const { result } = renderHook(() => useVoiceInputPreferences());
await waitFor(() => expect(result.current.isHydrated).toBe(true));
act(() => {
result.current.setSelectedProvider(null);
});
await waitFor(() => {
expect(save).toHaveBeenCalledWith({
values: [{ key: "voiceDictationProvider", value: "__disabled__" }],
});
});
});
it("clearing selected provider removes only the provider preference", async () => {
const remove = vi.fn().mockResolvedValue({});
mockGetClient.mockResolvedValue({
goose: {
GoosePreferencesRead: vi.fn().mockResolvedValue({
values: [
{ key: "voiceAutoSubmitPhrases", value: "submit" },
{ key: "voiceDictationProvider", value: "groq" },
{ key: "voiceDictationPreferredMic", value: "mic-1" },
],
}),
GoosePreferencesSave: vi.fn().mockResolvedValue({}),
GoosePreferencesRemove: remove,
},
});
const { result } = renderHook(() => useVoiceInputPreferences());
await waitFor(() => expect(result.current.isHydrated).toBe(true));
act(() => {
result.current.clearSelectedProvider();
});
await waitFor(() => {
expect(remove).toHaveBeenCalledWith({
keys: ["voiceDictationProvider"],
});
});
});
});
@@ -1,13 +1,14 @@
import { useCallback, useEffect, useState } from "react";
import { useCallback, useEffect, useRef, useState } from "react";
import { getClient } from "@/shared/api/acpConnection";
import {
AUTO_COMPACT_PREFERENCES_EVENT,
AUTO_COMPACT_THRESHOLD_CONFIG_KEY,
DEFAULT_AUTO_COMPACT_THRESHOLD,
normalizeAutoCompactThreshold,
} from "../lib/autoCompact";
const AUTO_COMPACT_RETRY_DELAY_MS = 1000;
const AUTO_COMPACT_INITIAL_RETRY_DELAY_MS = 1000;
const AUTO_COMPACT_MAX_RETRY_DELAY_MS = 30000;
const AUTO_COMPACT_THRESHOLD_PREFERENCE_KEY = "autoCompactThreshold";
type ConfigReadResult =
| {
@@ -18,22 +19,29 @@ type ConfigReadResult =
ok: false;
};
async function readConfigValue(key: string): Promise<ConfigReadResult> {
async function readAutoCompactThreshold(): Promise<ConfigReadResult> {
try {
const client = await getClient();
const response = await client.goose.GooseConfigRead({ key });
const response = await client.goose.GoosePreferencesRead({
keys: [AUTO_COMPACT_THRESHOLD_PREFERENCE_KEY],
});
const preference = response.values.find(
(value) => value.key === AUTO_COMPACT_THRESHOLD_PREFERENCE_KEY,
);
return {
ok: true,
value: response.value ?? null,
value: preference?.value ?? null,
};
} catch {
return { ok: false };
}
}
async function writeConfigValue(key: string, value: number): Promise<void> {
async function writeAutoCompactThreshold(value: number): Promise<void> {
const client = await getClient();
await client.goose.GooseConfigUpsert({ key, value });
await client.goose.GoosePreferencesSave({
values: [{ key: AUTO_COMPACT_THRESHOLD_PREFERENCE_KEY, value }],
});
}
export function useAutoCompactPreferences() {
@@ -41,31 +49,10 @@ export function useAutoCompactPreferences() {
DEFAULT_AUTO_COMPACT_THRESHOLD,
);
const [isHydrated, setIsHydrated] = useState(false);
const [syncVersion, setSyncVersion] = useState(0);
const retryDelayMsRef = useRef(AUTO_COMPACT_INITIAL_RETRY_DELAY_MS);
const requestSyncFromConfig = useCallback(() => {
setSyncVersion((current) => current + 1);
}, []);
useEffect(() => {
const handler = () => {
requestSyncFromConfig();
};
window.addEventListener(
AUTO_COMPACT_PREFERENCES_EVENT,
handler as EventListener,
);
return () => {
window.removeEventListener(
AUTO_COMPACT_PREFERENCES_EVENT,
handler as EventListener,
);
};
}, [requestSyncFromConfig]);
const syncFromConfig = useCallback(async (_syncVersion: number) => {
void _syncVersion;
const result = await readConfigValue(AUTO_COMPACT_THRESHOLD_CONFIG_KEY);
const syncFromConfig = useCallback(async () => {
const result = await readAutoCompactThreshold();
return result;
}, []);
@@ -73,8 +60,16 @@ export function useAutoCompactPreferences() {
let cancelled = false;
let retryTimer: number | null = null;
const clearRetryTimer = () => {
if (retryTimer !== null) {
window.clearTimeout(retryTimer);
retryTimer = null;
}
};
const applyConfig = async () => {
const result = await syncFromConfig(syncVersion);
clearRetryTimer();
const result = await syncFromConfig();
if (cancelled) {
return;
}
@@ -83,24 +78,40 @@ export function useAutoCompactPreferences() {
setAutoCompactThresholdState(
normalizeAutoCompactThreshold(result.value),
);
setIsHydrated(true);
retryDelayMsRef.current = AUTO_COMPACT_INITIAL_RETRY_DELAY_MS;
} else {
retryTimer = window.setTimeout(
requestSyncFromConfig,
AUTO_COMPACT_RETRY_DELAY_MS,
const delayMs = retryDelayMsRef.current;
retryDelayMsRef.current = Math.min(
delayMs * 2,
AUTO_COMPACT_MAX_RETRY_DELAY_MS,
);
retryTimer = window.setTimeout(() => {
void applyConfig();
}, delayMs);
}
setIsHydrated(true);
};
const handler = () => {
retryDelayMsRef.current = AUTO_COMPACT_INITIAL_RETRY_DELAY_MS;
void applyConfig();
};
window.addEventListener(
AUTO_COMPACT_PREFERENCES_EVENT,
handler as EventListener,
);
void applyConfig();
return () => {
cancelled = true;
if (retryTimer !== null) {
window.clearTimeout(retryTimer);
}
clearRetryTimer();
window.removeEventListener(
AUTO_COMPACT_PREFERENCES_EVENT,
handler as EventListener,
);
};
}, [requestSyncFromConfig, syncFromConfig, syncVersion]);
}, [syncFromConfig]);
const dispatchPreferencesEvent = useCallback(() => {
window.dispatchEvent(new Event(AUTO_COMPACT_PREFERENCES_EVENT));
@@ -109,7 +120,7 @@ export function useAutoCompactPreferences() {
const setAutoCompactThreshold = useCallback(
async (value: number) => {
const normalized = normalizeAutoCompactThreshold(value);
await writeConfigValue(AUTO_COMPACT_THRESHOLD_CONFIG_KEY, normalized);
await writeAutoCompactThreshold(normalized);
setAutoCompactThresholdState(normalized);
setIsHydrated(true);
dispatchPreferencesEvent();
@@ -15,8 +15,6 @@ import {
setStoredModelPreference,
} from "../lib/modelPreferences";
const GOOSE_PROVIDER_CONFIG_KEY = "GOOSE_PROVIDER";
const GOOSE_MODEL_CONFIG_KEY = "GOOSE_MODEL";
const MODEL_ALIAS_IDS = new Set(["current", "default"]);
export type PreferredModelSelection = {
@@ -134,23 +132,14 @@ export function useResolvedAgentModelPicker({
const loadGooseDefaultSelection = async () => {
try {
const client = await getClient();
const [providerResponse, modelResponse] = await Promise.all([
client.goose.GooseConfigRead({ key: GOOSE_PROVIDER_CONFIG_KEY }),
client.goose.GooseConfigRead({ key: GOOSE_MODEL_CONFIG_KEY }),
]);
const defaults = await client.goose.GooseDefaultsRead({});
if (cancelled) {
return;
}
const providerId =
typeof providerResponse.value === "string"
? providerResponse.value
: undefined;
const modelId =
typeof modelResponse.value === "string"
? modelResponse.value
: undefined;
const providerId = defaults.providerId ?? undefined;
const modelId = defaults.modelId ?? undefined;
if (!modelId) {
setGooseDefaultSelection(null);
@@ -3,47 +3,64 @@ import { getClient } from "@/shared/api/acpConnection";
import {
DEFAULT_AUTO_SUBMIT_PHRASES_RAW,
DISABLED_DICTATION_PROVIDER_CONFIG_VALUE,
VOICE_AUTO_SUBMIT_PHRASES_CONFIG_KEY,
VOICE_DICTATION_PREFERRED_MIC_CONFIG_KEY,
VOICE_DICTATION_PROVIDER_CONFIG_KEY,
normalizeDictationProvider,
parseAutoSubmitPhrases,
} from "../lib/voiceInput";
import type { DictationProvider } from "@/shared/types/dictation";
const VOICE_INPUT_PREFERENCES_EVENT = "goose:voice-input-preferences";
const VOICE_AUTO_SUBMIT_PHRASES_PREFERENCE_KEY = "voiceAutoSubmitPhrases";
const VOICE_DICTATION_PROVIDER_PREFERENCE_KEY = "voiceDictationProvider";
const VOICE_DICTATION_PREFERRED_MIC_PREFERENCE_KEY =
"voiceDictationPreferredMic";
type VoicePreferenceKey =
| typeof VOICE_AUTO_SUBMIT_PHRASES_PREFERENCE_KEY
| typeof VOICE_DICTATION_PROVIDER_PREFERENCE_KEY
| typeof VOICE_DICTATION_PREFERRED_MIC_PREFERENCE_KEY;
type ConfigReadResult = { ok: true; value: string | null } | { ok: false };
async function readConfigString(key: string): Promise<ConfigReadResult> {
async function readPreferenceStrings(
keys: VoicePreferenceKey[],
): Promise<Record<VoicePreferenceKey, ConfigReadResult>> {
const unavailable = Object.fromEntries(
keys.map((key) => [key, { ok: false }]),
) as Record<VoicePreferenceKey, ConfigReadResult>;
try {
const client = await getClient();
const response = await client.goose.GooseConfigRead({ key });
return {
ok: true,
value: typeof response.value === "string" ? response.value : null,
};
const response = await client.goose.GoosePreferencesRead({ keys });
const values = new Map(
response.values.map((entry) => [entry.key, entry.value]),
);
return Object.fromEntries(
keys.map((key) => {
const value = values.get(key);
return [
key,
{
ok: true,
value: typeof value === "string" ? value : null,
},
];
}),
) as Record<VoicePreferenceKey, ConfigReadResult>;
} catch {
return { ok: false };
return unavailable;
}
}
async function writeConfigString(key: string, value: string): Promise<void> {
try {
const client = await getClient();
await client.goose.GooseConfigUpsert({ key, value });
} catch {
// goose config may be unavailable
}
async function writePreferenceString(
key: VoicePreferenceKey,
value: string,
): Promise<void> {
const client = await getClient();
await client.goose.GoosePreferencesSave({ values: [{ key, value }] });
}
async function removeConfigKey(key: string): Promise<void> {
try {
const client = await getClient();
await client.goose.GooseConfigRemove({ key });
} catch {
// goose config may be unavailable
}
async function removePreferenceKey(key: VoicePreferenceKey): Promise<void> {
const client = await getClient();
await client.goose.GoosePreferencesRemove({ keys: [key] });
}
export function useVoiceInputPreferences() {
@@ -65,11 +82,14 @@ export function useVoiceInputPreferences() {
const [isHydrated, setIsHydrated] = useState(false);
const syncFromConfig = useCallback(async () => {
const [phrasesResult, providerResult, micResult] = await Promise.all([
readConfigString(VOICE_AUTO_SUBMIT_PHRASES_CONFIG_KEY),
readConfigString(VOICE_DICTATION_PROVIDER_CONFIG_KEY),
readConfigString(VOICE_DICTATION_PREFERRED_MIC_CONFIG_KEY),
const results = await readPreferenceStrings([
VOICE_AUTO_SUBMIT_PHRASES_PREFERENCE_KEY,
VOICE_DICTATION_PROVIDER_PREFERENCE_KEY,
VOICE_DICTATION_PREFERRED_MIC_PREFERENCE_KEY,
]);
const phrasesResult = results[VOICE_AUTO_SUBMIT_PHRASES_PREFERENCE_KEY];
const providerResult = results[VOICE_DICTATION_PROVIDER_PREFERENCE_KEY];
const micResult = results[VOICE_DICTATION_PREFERRED_MIC_PREFERENCE_KEY];
if (phrasesResult.ok) {
setRawAutoSubmitPhrasesState(
@@ -99,7 +119,9 @@ export function useVoiceInputPreferences() {
// through to the default cleanly.
setSelectedProviderState(null);
setHasStoredProviderPreferenceState(false);
void removeConfigKey(VOICE_DICTATION_PROVIDER_CONFIG_KEY);
void removePreferenceKey(VOICE_DICTATION_PROVIDER_PREFERENCE_KEY).catch(
() => undefined,
);
}
} else {
setSelectedProviderState(null);
@@ -135,18 +157,23 @@ export function useVoiceInputPreferences() {
const persistAndBroadcast = useCallback(
(operation: Promise<void>) => {
void operation.finally(() => {
dispatchPreferencesEvent();
});
void operation
.then(() => {
dispatchPreferencesEvent();
})
.catch((error: unknown) => {
console.warn("Failed to persist voice input preferences", error);
void syncFromConfig();
});
},
[dispatchPreferencesEvent],
[dispatchPreferencesEvent, syncFromConfig],
);
const setRawAutoSubmitPhrases = useCallback(
(value: string) => {
setRawAutoSubmitPhrasesState(value);
persistAndBroadcast(
writeConfigString(VOICE_AUTO_SUBMIT_PHRASES_CONFIG_KEY, value),
writePreferenceString(VOICE_AUTO_SUBMIT_PHRASES_PREFERENCE_KEY, value),
);
},
[persistAndBroadcast],
@@ -157,8 +184,8 @@ export function useVoiceInputPreferences() {
setSelectedProviderState(value);
setHasStoredProviderPreferenceState(true);
persistAndBroadcast(
writeConfigString(
VOICE_DICTATION_PROVIDER_CONFIG_KEY,
writePreferenceString(
VOICE_DICTATION_PROVIDER_PREFERENCE_KEY,
value ?? DISABLED_DICTATION_PROVIDER_CONFIG_VALUE,
),
);
@@ -172,7 +199,9 @@ export function useVoiceInputPreferences() {
const clearSelectedProvider = useCallback(() => {
setSelectedProviderState(null);
setHasStoredProviderPreferenceState(false);
persistAndBroadcast(removeConfigKey(VOICE_DICTATION_PROVIDER_CONFIG_KEY));
persistAndBroadcast(
removePreferenceKey(VOICE_DICTATION_PROVIDER_PREFERENCE_KEY),
);
}, [persistAndBroadcast]);
const setPreferredMicrophoneId = useCallback(
@@ -180,11 +209,14 @@ export function useVoiceInputPreferences() {
setPreferredMicrophoneIdState(value);
if (value) {
persistAndBroadcast(
writeConfigString(VOICE_DICTATION_PREFERRED_MIC_CONFIG_KEY, value),
writePreferenceString(
VOICE_DICTATION_PREFERRED_MIC_PREFERENCE_KEY,
value,
),
);
} else {
persistAndBroadcast(
removeConfigKey(VOICE_DICTATION_PREFERRED_MIC_CONFIG_KEY),
removePreferenceKey(VOICE_DICTATION_PREFERRED_MIC_PREFERENCE_KEY),
);
}
},
@@ -3,8 +3,7 @@ import type {
DictationProviderStatus,
} from "@/shared/types/dictation";
// goose config keys — stored in the user's goose config.yaml via the
// _goose/config/{read,upsert,remove} ACP methods, not localStorage.
// Stored in the user's goose config.yaml via typed ACP preference methods, not localStorage.
export const VOICE_AUTO_SUBMIT_PHRASES_CONFIG_KEY = "VOICE_AUTO_SUBMIT_PHRASES";
export const VOICE_DICTATION_PROVIDER_CONFIG_KEY = "VOICE_DICTATION_PROVIDER";
export const VOICE_DICTATION_PREFERRED_MIC_CONFIG_KEY =
+12 -13
View File
@@ -3,7 +3,6 @@ import {
type AppRendererProps,
type McpUiHostContext,
} from "@mcp-ui/client";
import type { GooseToolCallResponse } from "@aaif/goose-sdk";
import { useCallback, useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import packageJson from "../../../../package.json";
@@ -31,18 +30,13 @@ interface McpAppViewProps {
const DEFAULT_APP_HEIGHT = 240;
// Goose2 currently only implements inline display mode.
type HostContextDisplayMode = NonNullable<
const GOOSE2_DISPLAY_MODE = "inline" satisfies NonNullable<
McpUiHostContext["displayMode"]
>;
const AVAILABLE_DISPLAY_MODES: NonNullable<
McpUiHostContext["availableDisplayModes"]
>[number];
type AvailableDisplayMode = Extract<HostContextDisplayMode, "inline">;
const AVAILABLE_DISPLAY_MODES = [
"inline",
] satisfies readonly AvailableDisplayMode[];
> = [GOOSE2_DISPLAY_MODE];
const GOOSE2_USER_AGENT = `${packageJson.name}/${packageJson.version}`;
const GOOSE2_HOST_INFO = {
name: packageJson.name,
version: packageJson.version,
} satisfies NonNullable<AppRendererProps["hostInfo"]>;
const DESKTOP_SAFE_AREA_INSETS = {
top: 0,
right: 0,
@@ -59,6 +53,12 @@ type CallToolResult = Awaited<
type ReadResourceResult = Awaited<
ReturnType<NonNullable<AppRendererProps["onReadResource"]>>
>;
type GooseToolCallResponse = {
content?: unknown[];
structuredContent?: unknown;
isError: boolean;
_meta?: unknown;
};
type HostContextToolInfo = NonNullable<McpUiHostContext["toolInfo"]>;
type HostContextTool = HostContextToolInfo["tool"];
@@ -272,7 +272,7 @@ export function McpAppView({
const hostContext = useMemo<McpUiHostContext>(
() => ({
theme: resolvedTheme,
displayMode: "inline",
displayMode: GOOSE2_DISPLAY_MODE,
availableDisplayModes: [...AVAILABLE_DISPLAY_MODES],
containerDimensions:
containerWidth !== null
@@ -426,7 +426,6 @@ export function McpAppView({
toolResourceUri={renderableDocument.resourceUri}
html={renderableDocument.html}
sandbox={sandbox}
hostInfo={GOOSE2_HOST_INFO}
toolInput={currentToolInput}
toolResult={currentToolResult}
hostContext={hostContext}
@@ -260,7 +260,7 @@ describe("McpAppView nested tool calls", () => {
expect(borderlessChrome?.className).not.toContain("overflow-hidden");
});
it("passes Goose2 package identity as host info", async () => {
it("passes Goose2 package identity in host context", async () => {
render(
<McpAppView
payload={createPayload()}
@@ -272,10 +272,9 @@ describe("McpAppView nested tool calls", () => {
expect(screen.getByTestId("mock-app-renderer")).toBeInTheDocument();
});
expect(getLatestAppRendererProps().hostInfo).toEqual({
name: packageJson.name,
version: packageJson.version,
});
expect(getLatestAppRendererProps().hostContext?.userAgent).toBe(
`${packageJson.name}/${packageJson.version}`,
);
});
it("does not install a fallback handler for non-standard app requests", async () => {
@@ -79,6 +79,12 @@ describe("useCredentials", () => {
});
it("saves secret fields through the credential API and syncs inventory without requiring restart", async () => {
const voiceConfigListener = vi.fn();
window.addEventListener(
"goose:voice-dictation-config",
voiceConfigListener,
);
const { result } = renderHook(() => useCredentials());
await waitFor(() => expect(result.current.loading).toBe(false));
@@ -110,8 +116,14 @@ describe("useCredentials", () => {
initialRefresh: saveResponse.refresh,
}),
);
expect(voiceConfigListener).toHaveBeenCalledTimes(1);
expect(result.current).not.toHaveProperty("needsRestart");
expect(result.current).not.toHaveProperty("restart");
window.removeEventListener(
"goose:voice-dictation-config",
voiceConfigListener,
);
});
it("records refresh failure as a provider warning without rejecting the save", async () => {
@@ -140,6 +152,12 @@ describe("useCredentials", () => {
});
it("suppresses stale refresh errors after deleting provider config", async () => {
const voiceConfigListener = vi.fn();
window.addEventListener(
"goose:voice-dictation-config",
voiceConfigListener,
);
mocks.syncProviderInventory.mockResolvedValueOnce({
entries: [
{
@@ -162,7 +180,13 @@ describe("useCredentials", () => {
await waitFor(() =>
expect(result.current.syncingProviderIds.has("anthropic")).toBe(false),
);
expect(voiceConfigListener).toHaveBeenCalledTimes(1);
expect(result.current.inventoryWarnings.has("anthropic")).toBe(false);
window.removeEventListener(
"goose:voice-dictation-config",
voiceConfigListener,
);
});
it("invalidates native OAuth secrets before refreshing provider status", async () => {
@@ -6,6 +6,7 @@ import {
type ProviderStatus,
checkAllProviderStatus,
} from "@/features/providers/api/credentials";
import { notifyVoiceDictationConfigChanged } from "@/features/chat/lib/voiceInput";
import {
syncProviderInventory,
type SyncProviderInventoryResult,
@@ -208,6 +209,7 @@ export function useCredentials(): UseCredentialsReturn {
fields.map(({ key, value }) => ({ key, value })),
);
updateProviderStatus(result.status);
notifyVoiceDictationConfigChanged();
startInventorySync(providerId, result.refresh);
} finally {
setProviderSaving(providerId, false);
@@ -222,6 +224,7 @@ export function useCredentials(): UseCredentialsReturn {
try {
const result = await deleteProviderConfig(providerId);
updateProviderStatus(result.status);
notifyVoiceDictationConfigChanged();
startInventorySync(providerId, result.refresh);
} finally {
setProviderSaving(providerId, false);
@@ -241,6 +244,7 @@ export function useCredentials(): UseCredentialsReturn {
setProviderInventoryWarning(providerId, errorMessage(error));
}
await refreshStatuses();
notifyVoiceDictationConfigChanged();
startInventorySync(providerId, initialRefresh);
},
[refreshStatuses, setProviderInventoryWarning, startInventorySync],
@@ -58,6 +58,10 @@ export function SettingsModal({
return () => clearTimeout(timer);
}, []);
useEffect(() => {
setActiveSection(initialSection);
}, [initialSection]);
useEffect(() => {
const handleKeyDown = (event: KeyboardEvent) => {
if (
@@ -11,6 +11,7 @@ import {
getDefaultDictationProvider,
} from "@/features/chat/lib/voiceInput";
import { useVoiceInputPreferences } from "@/features/chat/hooks/useVoiceInputPreferences";
import { requestOpenSettings } from "@/features/settings/lib/settingsEvents";
import type {
DictationProvider,
DictationProviderStatus,
@@ -145,11 +146,7 @@ export function VoiceInputSettings() {
setError(null);
try {
await saveDictationProviderSecret(
selectedProvider,
apiKeyInput,
selectedStatus?.configKey ?? undefined,
);
await saveDictationProviderSecret(selectedProvider, apiKeyInput);
setApiKeyInput("");
setIsEditingApiKey(false);
await refreshConfig();
@@ -161,7 +158,7 @@ export function VoiceInputSettings() {
: t("general.voiceInput.saveError"),
);
}
}, [apiKeyInput, refreshConfig, selectedProvider, selectedStatus, t]);
}, [apiKeyInput, refreshConfig, selectedProvider, t]);
const removeApiKey = useCallback(async () => {
if (!selectedProvider) {
@@ -170,10 +167,7 @@ export function VoiceInputSettings() {
setError(null);
try {
await deleteDictationProviderSecret(
selectedProvider,
selectedStatus?.configKey ?? undefined,
);
await deleteDictationProviderSecret(selectedProvider);
setApiKeyInput("");
setIsEditingApiKey(false);
await refreshConfig();
@@ -185,7 +179,7 @@ export function VoiceInputSettings() {
: t("general.voiceInput.deleteError"),
);
}
}, [refreshConfig, selectedProvider, selectedStatus, t]);
}, [refreshConfig, selectedProvider, t]);
const handleModelChange = useCallback(
async (modelId: string) => {
@@ -333,6 +327,27 @@ export function VoiceInputSettings() {
{selectedStatus ? (
<>
{selectedStatus.usesProviderConfig ? (
<div className="space-y-3 rounded-lg border border-border px-3 py-3">
<div>
<p className="text-xs font-medium text-foreground">
{t("general.voiceInput.providerConfigLabel")}
</p>
<p className="mt-1 text-xs text-muted-foreground">
{t("general.voiceInput.providerConfigDescription")}
</p>
</div>
<Button
type="button"
size="sm"
variant="outline-flat"
onClick={() => requestOpenSettings("providers")}
>
{t("general.voiceInput.openProviders")}
</Button>
</div>
) : null}
{!selectedStatus.usesProviderConfig &&
selectedProvider !== "local" ? (
<div className="space-y-3 rounded-lg border border-border px-3 py-3">
@@ -0,0 +1,98 @@
import { render, screen, waitFor } from "@testing-library/react";
import userEvent from "@testing-library/user-event";
import { beforeEach, describe, expect, it, vi } from "vitest";
import { VoiceInputSettings } from "../VoiceInputSettings";
const mockGetDictationConfig = vi.fn();
const mockUseVoiceInputPreferences = vi.fn();
vi.mock("@/shared/api/dictation", () => ({
getDictationConfig: () => mockGetDictationConfig(),
saveDictationModelSelection: vi.fn(),
saveDictationProviderSecret: vi.fn(),
deleteDictationProviderSecret: vi.fn(),
}));
vi.mock("@/features/chat/hooks/useVoiceInputPreferences", () => ({
useVoiceInputPreferences: () => mockUseVoiceInputPreferences(),
}));
vi.mock("@/shared/ui/ai-elements/mic-selector", () => ({
useAudioDevices: () => ({
devices: [],
error: null,
hasPermission: false,
loadDevices: vi.fn(),
loading: false,
}),
}));
vi.mock("../LocalWhisperModels", () => ({
LocalWhisperModels: () => <div />,
}));
describe("VoiceInputSettings", () => {
beforeEach(() => {
mockGetDictationConfig.mockReset();
mockUseVoiceInputPreferences.mockReset();
mockUseVoiceInputPreferences.mockReturnValue({
clearSelectedProvider: vi.fn(),
hasStoredProviderPreference: true,
isHydrated: true,
preferredMicrophoneId: null,
rawAutoSubmitPhrases: "submit",
selectedProvider: "openai",
setPreferredMicrophoneId: vi.fn(),
setRawAutoSubmitPhrases: vi.fn(),
setSelectedProvider: vi.fn(),
});
mockGetDictationConfig.mockResolvedValue({
openai: {
configured: false,
description: "Uses OpenAI Whisper API for high-quality transcription.",
usesProviderConfig: true,
settingsPath: "Settings > Models",
configKey: null,
modelConfigKey: "OPENAI_TRANSCRIPTION_MODEL",
defaultModel: "whisper-1",
selectedModel: null,
availableModels: [
{
id: "whisper-1",
label: "Whisper-1",
description: "OpenAI's hosted Whisper transcription model.",
},
],
},
});
});
it("points OpenAI Whisper setup to provider settings", async () => {
const user = userEvent.setup();
const openSettingsListener = vi.fn();
window.addEventListener("goose:open-settings", openSettingsListener);
render(<VoiceInputSettings />);
await waitFor(() =>
expect(screen.getByText("Provider credentials")).toBeInTheDocument(),
);
expect(
screen.getByText(
"This transcription provider uses the credentials from its model provider setup.",
),
).toBeInTheDocument();
expect(
screen.queryByRole("button", { name: "Add API key" }),
).not.toBeInTheDocument();
await user.click(screen.getByRole("button", { name: "Open Providers" }));
expect(openSettingsListener).toHaveBeenCalledTimes(1);
expect(openSettingsListener.mock.calls[0][0]).toMatchObject({
detail: { section: "providers" },
});
window.removeEventListener("goose:open-settings", openSettingsListener);
});
});
@@ -1,161 +0,0 @@
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";
vi.mock("../acpConnection", () => ({
getClient: vi.fn(),
}));
describe("dictation SDK wiring", () => {
let client: { goose: Record<string, ReturnType<typeof vi.fn>> };
beforeEach(() => {
client = {
goose: {
GooseDictationConfig: vi.fn().mockResolvedValue({
providers: {
openai: {
configured: true,
description: "OpenAI transcription",
usesProviderConfig: true,
availableModels: [],
},
},
}),
GooseDictationTranscribe: vi.fn().mockResolvedValue({ text: "hello" }),
},
};
vi.mocked(getClient).mockResolvedValue(
client as unknown as Awaited<ReturnType<typeof getClient>>,
);
});
it("getDictationConfig calls GooseDictationConfig and returns providers map", async () => {
const result = await getDictationConfig();
expect(client.goose.GooseDictationConfig).toHaveBeenCalledWith({});
expect(result.openai.configured).toBe(true);
});
it("transcribeDictation forwards audio + mimeType + provider", async () => {
const result = await transcribeDictation({
audio: "base64==",
mimeType: "audio/webm",
provider: "openai",
});
expect(client.goose.GooseDictationTranscribe).toHaveBeenCalledWith({
audio: "base64==",
mimeType: "audio/webm",
provider: "openai",
});
expect(result.text).toBe("hello");
});
it("saveDictationModelSelection calls GooseDictationModelSelect", async () => {
client.goose.GooseDictationModelSelect = vi.fn().mockResolvedValue({});
await saveDictationModelSelection("local", "tiny");
expect(client.goose.GooseDictationModelSelect).toHaveBeenCalledWith({
provider: "local",
modelId: "tiny",
});
});
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: [
{
id: "tiny",
description: "Tiny",
sizeMb: 75,
downloaded: true,
downloadInProgress: false,
},
],
});
const result = await listDictationLocalModels();
expect(client.goose.GooseDictationModelsList).toHaveBeenCalledWith({});
expect(result).toHaveLength(1);
expect(result[0].id).toBe("tiny");
});
it("downloadDictationLocalModel forwards modelId", async () => {
client.goose.GooseDictationModelsDownload = vi.fn().mockResolvedValue({});
await downloadDictationLocalModel("tiny");
expect(client.goose.GooseDictationModelsDownload).toHaveBeenCalledWith({
modelId: "tiny",
});
});
it("getDictationLocalModelDownloadProgress returns progress or null", async () => {
client.goose.GooseDictationModelsDownloadProgress = vi
.fn()
.mockResolvedValue({
progress: {
bytesDownloaded: 100,
totalBytes: 1000,
progressPercent: 10,
status: "downloading",
error: null,
},
});
const result = await getDictationLocalModelDownloadProgress("tiny");
expect(result?.bytesDownloaded).toBe(100);
expect(
client.goose.GooseDictationModelsDownloadProgress,
).toHaveBeenCalledWith({
modelId: "tiny",
});
});
it("getDictationLocalModelDownloadProgress returns null when no download", async () => {
client.goose.GooseDictationModelsDownloadProgress = vi
.fn()
.mockResolvedValue({
progress: undefined,
});
const result = await getDictationLocalModelDownloadProgress("tiny");
expect(result).toBeNull();
});
it("cancelDictationLocalModelDownload forwards modelId", async () => {
client.goose.GooseDictationModelsCancel = vi.fn().mockResolvedValue({});
await cancelDictationLocalModelDownload("tiny");
expect(client.goose.GooseDictationModelsCancel).toHaveBeenCalledWith({
modelId: "tiny",
});
});
it("deleteDictationLocalModel forwards modelId", async () => {
client.goose.GooseDictationModelsDelete = vi.fn().mockResolvedValue({});
await deleteDictationLocalModel("tiny");
expect(client.goose.GooseDictationModelsDelete).toHaveBeenCalledWith({
modelId: "tiny",
});
});
});
+4 -12
View File
@@ -40,26 +40,18 @@ export async function saveDictationModelSelection(
}
export async function saveDictationProviderSecret(
_provider: DictationProvider,
provider: DictationProvider,
value: string,
configKey?: string,
): Promise<void> {
if (!configKey) {
throw new Error("No config key for this provider");
}
const client = await getClient();
await client.goose.GooseSecretUpsert({ key: configKey, value });
await client.goose.GooseDictationSecretSave({ provider, value });
}
export async function deleteDictationProviderSecret(
_provider: DictationProvider,
configKey?: string,
provider: DictationProvider,
): Promise<void> {
if (!configKey) {
throw new Error("Cannot delete secrets for this provider");
}
const client = await getClient();
await client.goose.GooseSecretRemove({ key: configKey });
await client.goose.GooseDictationSecretDelete({ provider });
}
export async function listDictationLocalModels(): Promise<
@@ -176,6 +176,9 @@
"addApiKey": "Add API key",
"updateApiKey": "Update API key",
"removeApiKey": "Remove API key",
"providerConfigLabel": "Provider credentials",
"providerConfigDescription": "This transcription provider uses the credentials from its model provider setup.",
"openProviders": "Open Providers",
"localModelLabel": "Local Whisper Model",
"localModelDescription": "Download a Whisper model to run transcription locally. Selecting a model sets it as your active local transcription model.",
"noLocalModels": "No local Whisper models available.",
@@ -176,6 +176,9 @@
"addApiKey": "Agregar clave API",
"updateApiKey": "Actualizar clave API",
"removeApiKey": "Eliminar clave API",
"providerConfigLabel": "Credenciales del proveedor",
"providerConfigDescription": "Este proveedor de transcripción usa las credenciales de la configuración de su proveedor de modelos.",
"openProviders": "Abrir proveedores",
"localModelLabel": "Modelo Whisper local",
"localModelDescription": "Descarga un modelo Whisper para transcribir localmente. Seleccionar un modelo lo establece como tu modelo de transcripción local activo.",
"noLocalModels": "No hay modelos Whisper locales disponibles.",