diff --git a/apps/code/src/renderer/desktop-services.ts b/apps/code/src/renderer/desktop-services.ts index 140113505c..43d828b11a 100644 --- a/apps/code/src/renderer/desktop-services.ts +++ b/apps/code/src/renderer/desktop-services.ts @@ -64,6 +64,7 @@ import { type Adapter, AUTORESEARCH_FLAG, type CloudRegion, + SPOKEN_NARRATION_FLAG, } from "@posthog/shared"; import { AUTH_SIDE_EFFECTS, @@ -386,7 +387,9 @@ container get: () => { const s = useSettingsStore.getState(); return { - enabled: s.spokenNotifications, + enabled: + s.spokenNotifications && + posthogFeatureFlags.isEnabled(SPOKEN_NARRATION_FLAG), voiceId: s.elevenLabsVoiceId || undefined, }; }, @@ -426,7 +429,9 @@ container.bind(SPEECH_NOTIFY_SETTINGS).toConstantValue({ get: () => { const s = useSettingsStore.getState(); return { - enabled: s.spokenNotifications, + enabled: + s.spokenNotifications && + posthogFeatureFlags.isEnabled(SPOKEN_NARRATION_FLAG), needsInput: s.spokenNotifyNeedsInput, completion: s.spokenNotifyCompletion, progress: s.spokenNotifyProgress, diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index 4694203f93..394620132e 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -94,6 +94,7 @@ import { import { selectSessionsToEvict } from "./sessionEviction"; import { createBaseSession } from "./sessionFactory"; import { type ParsedSessionLogs, parseSessionLogContent } from "./sessionLogs"; +import { resolveLocalSpokenNarration } from "./spokenNarration"; const LOCAL_SESSION_RECONNECT_ATTEMPTS = 3; const LOCAL_SESSION_RECONNECT_BACKOFF = { @@ -337,6 +338,9 @@ export interface SessionServiceDeps { rtkEnabledCloud?: boolean; spokenNotifications?: boolean; elevenLabsKeyConfigured?: boolean; + // The spoken-narration rollout flag, evaluated by the host (core has no + // feature-flag client). Absent means off — narration stays fail-closed. + spokenNarrationFlagEnabled?: boolean; }; usageLimit: { show: (...args: any[]) => any }; readonly addDirectoryDialog: { open: boolean }; @@ -1088,19 +1092,13 @@ export class SessionService { this.d.log.warn("Failed to verify workspace", { taskId, err }); }); - const { - customInstructions, - rtkEnabledLocal, - spokenNotifications, - elevenLabsKeyConfigured, - } = this.d.settings; + const { customInstructions, rtkEnabledLocal } = this.d.settings; const result = await this.d.trpc.agent.reconnect.mutate({ taskId, taskRunId, repoPath, rtkEnabled: rtkEnabledLocal, - spokenNarration: - spokenNotifications === true && elevenLabsKeyConfigured === true, + spokenNarration: resolveLocalSpokenNarration(this.d.settings), apiHost: auth.apiHost, projectId: auth.projectId, logUrl, @@ -1420,12 +1418,8 @@ export class SessionService { throw new Error("Failed to create task run. Please try again."); } - const { - customInstructions: startCustomInstructions, - rtkEnabledLocal, - spokenNotifications, - elevenLabsKeyConfigured, - } = this.d.settings; + const { customInstructions: startCustomInstructions, rtkEnabledLocal } = + this.d.settings; const preferredModel = model ?? this.d.DEFAULT_GATEWAY_MODEL; const result = await this.d.trpc.agent.start.mutate({ taskId, @@ -1437,8 +1431,7 @@ export class SessionService { adapter, customInstructions: startCustomInstructions || undefined, rtkEnabled: rtkEnabledLocal, - spokenNarration: - spokenNotifications === true && elevenLabsKeyConfigured === true, + spokenNarration: resolveLocalSpokenNarration(this.d.settings), effort: effortLevelSchema.safeParse(reasoningLevel).success ? (reasoningLevel as EffortLevel) : undefined, diff --git a/packages/core/src/sessions/spokenNarration.test.ts b/packages/core/src/sessions/spokenNarration.test.ts new file mode 100644 index 0000000000..5fdd14174f --- /dev/null +++ b/packages/core/src/sessions/spokenNarration.test.ts @@ -0,0 +1,51 @@ +import { describe, expect, it } from "vitest"; +import { resolveLocalSpokenNarration } from "./spokenNarration"; + +describe("resolveLocalSpokenNarration", () => { + it("enables narration only when flag, setting and key are all present", () => { + expect( + resolveLocalSpokenNarration({ + spokenNarrationFlagEnabled: true, + spokenNotifications: true, + elevenLabsKeyConfigured: true, + }), + ).toBe(true); + }); + + it.each([ + { + name: "flag off", + settings: { + spokenNarrationFlagEnabled: false, + spokenNotifications: true, + elevenLabsKeyConfigured: true, + }, + }, + { + name: "flag unset (host without flags)", + settings: { + spokenNotifications: true, + elevenLabsKeyConfigured: true, + }, + }, + { + name: "setting off", + settings: { + spokenNarrationFlagEnabled: true, + spokenNotifications: false, + elevenLabsKeyConfigured: true, + }, + }, + { + name: "no ElevenLabs key", + settings: { + spokenNarrationFlagEnabled: true, + spokenNotifications: true, + elevenLabsKeyConfigured: false, + }, + }, + { name: "everything unset", settings: {} }, + ])("stays off with $name", ({ settings }) => { + expect(resolveLocalSpokenNarration(settings)).toBe(false); + }); +}); diff --git a/packages/core/src/sessions/spokenNarration.ts b/packages/core/src/sessions/spokenNarration.ts new file mode 100644 index 0000000000..b0f72c4e45 --- /dev/null +++ b/packages/core/src/sessions/spokenNarration.ts @@ -0,0 +1,19 @@ +/** + * Whether a local desktop session should enable agent spoken narration + * (the `speak` tool and its prompt instructions). Strictly opt-in: the + * rollout feature flag, the user's spoken-notifications setting, and a + * configured ElevenLabs key must all be present. The host supplies each + * fact through `SessionServiceDeps.settings`; this rule keeps the gate in + * one tested place for both the start and reconnect paths. + */ +export function resolveLocalSpokenNarration(settings: { + spokenNarrationFlagEnabled?: boolean; + spokenNotifications?: boolean; + elevenLabsKeyConfigured?: boolean; +}): boolean { + return ( + settings.spokenNarrationFlagEnabled === true && + settings.spokenNotifications === true && + settings.elevenLabsKeyConfigured === true + ); +} diff --git a/packages/ui/src/features/sessions/sessionServiceHost.ts b/packages/ui/src/features/sessions/sessionServiceHost.ts index 44bfc7a9f7..5c87628e5e 100644 --- a/packages/ui/src/features/sessions/sessionServiceHost.ts +++ b/packages/ui/src/features/sessions/sessionServiceHost.ts @@ -15,12 +15,17 @@ import { HOST_TRPC_CLIENT, type HostTrpcClient, } from "@posthog/host-router/client"; +import { SPOKEN_NARRATION_FLAG } from "@posthog/shared"; import { createAuthenticatedClient, getAuthenticatedClient, } from "@posthog/ui/features/auth/authClientImperative"; import { fetchAuthState } from "@posthog/ui/features/auth/authQueries"; import { useUsageLimitStore } from "@posthog/ui/features/billing/usageLimitStore"; +import { + FEATURE_FLAGS, + type FeatureFlags, +} from "@posthog/ui/features/feature-flags/identifiers"; import { useAddDirectoryDialogStore } from "@posthog/ui/features/folder-picker/addDirectoryDialogStore"; import { NotificationBus } from "@posthog/ui/features/notifications/notifications"; import { SpeechNotifier } from "@posthog/ui/features/notifications/speechNotifier"; @@ -57,6 +62,18 @@ function hostClient(): HostTrpcClient { return resolveService(HOST_TRPC_CLIENT); } +// Fail closed: a host without flags (or flags not yet loaded) means the +// spoken-narration rollout is off for this session start. +function spokenNarrationFlagEnabled(): boolean { + try { + return resolveService(FEATURE_FLAGS).isEnabled( + SPOKEN_NARRATION_FLAG, + ); + } catch { + return false; + } +} + function buildSessionServiceDeps(): SessionServiceDeps { const trpc = hostClient(); const queryClient = resolveService( @@ -117,6 +134,7 @@ function buildSessionServiceDeps(): SessionServiceDeps { return { ...state, customInstructions: getEffectiveCustomInstructions(state), + spokenNarrationFlagEnabled: spokenNarrationFlagEnabled(), }; }, usageLimit: {