Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 7 additions & 2 deletions apps/code/src/renderer/desktop-services.ts
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ import {
type Adapter,
AUTORESEARCH_FLAG,
type CloudRegion,
SPOKEN_NARRATION_FLAG,
} from "@posthog/shared";
import {
AUTH_SIDE_EFFECTS,
Expand Down Expand Up @@ -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,
};
},
Expand Down Expand Up @@ -426,7 +429,9 @@ container.bind<ISpeechNotifySettings>(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,
Expand Down
25 changes: 9 additions & 16 deletions packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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 };
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
Expand All @@ -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,
Expand Down
51 changes: 51 additions & 0 deletions packages/core/src/sessions/spokenNarration.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
19 changes: 19 additions & 0 deletions packages/core/src/sessions/spokenNarration.ts
Original file line number Diff line number Diff line change
@@ -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
);
}
18 changes: 18 additions & 0 deletions packages/ui/src/features/sessions/sessionServiceHost.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -57,6 +62,18 @@ function hostClient(): HostTrpcClient {
return resolveService<HostTrpcClient>(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<FeatureFlags>(FEATURE_FLAGS).isEnabled(
SPOKEN_NARRATION_FLAG,
);
} catch {
return false;
}
}

function buildSessionServiceDeps(): SessionServiceDeps {
const trpc = hostClient();
const queryClient = resolveService<ImperativeQueryClient>(
Expand Down Expand Up @@ -117,6 +134,7 @@ function buildSessionServiceDeps(): SessionServiceDeps {
return {
...state,
customInstructions: getEffectiveCustomInstructions(state),
spokenNarrationFlagEnabled: spokenNarrationFlagEnabled(),
};
},
usageLimit: {
Expand Down