diff --git a/apps/mobile/src/app/task/[id].tsx b/apps/mobile/src/app/task/[id].tsx
index 1375725a49..cd0a4c4843 100644
--- a/apps/mobile/src/app/task/[id].tsx
+++ b/apps/mobile/src/app/task/[id].tsx
@@ -15,6 +15,7 @@ import Animated, { useAnimatedStyle } from "react-native-reanimated";
import { FloatingBackButton } from "@/components/FloatingBackButton";
import { usePreferencesStore } from "@/features/preferences/stores/preferencesStore";
import { getTask, runTaskInCloud } from "@/features/tasks/api";
+import { CustomImageBadge } from "@/features/tasks/components/CustomImageBadge";
import { FloatingTaskHeader } from "@/features/tasks/components/FloatingTaskHeader";
import { PrDiffStatsBadge } from "@/features/tasks/components/PrDiffStatsBadge";
import { PrStatusBadge } from "@/features/tasks/components/PrStatusBadge";
@@ -667,14 +668,17 @@ export default function TaskDetailScreen() {
title={showLoading ? "Loading..." : task?.title || "Task"}
subtitle={task?.repository ?? undefined}
rightSlot={
- canStopRun ? (
-
- ) : prUrl ? (
- <>
-
-
- >
- ) : null
+ <>
+ {task ? : null}
+ {canStopRun ? (
+
+ ) : prUrl ? (
+ <>
+
+
+ >
+ ) : null}
+ >
}
/>
diff --git a/apps/mobile/src/features/tasks/api.ts b/apps/mobile/src/features/tasks/api.ts
index e2833e505e..6952fde9f6 100644
--- a/apps/mobile/src/features/tasks/api.ts
+++ b/apps/mobile/src/features/tasks/api.ts
@@ -1,4 +1,8 @@
import type { Adapter } from "@posthog/shared";
+import type {
+ SandboxCustomImage,
+ SandboxEnvironment,
+} from "@posthog/shared/domain-types";
import { fetch } from "expo/fetch";
import {
authedFetch,
@@ -779,6 +783,50 @@ export async function streamCloudTask(
});
}
+export async function getSandboxCustomImages(): Promise {
+ const baseUrl = getBaseUrl();
+ const projectId = getProjectId();
+
+ const response = await authedFetch(
+ `${baseUrl}/api/projects/${projectId}/sandbox_custom_images/`,
+ );
+
+ if (!response.ok) {
+ throw new HttpError(
+ response.status,
+ response.statusText,
+ "Failed to fetch sandbox custom images",
+ );
+ }
+
+ const data = await parseJsonResponse<{ results?: SandboxCustomImage[] }>(
+ response,
+ );
+ return data.results ?? [];
+}
+
+export async function getSandboxEnvironments(): Promise {
+ const baseUrl = getBaseUrl();
+ const projectId = getProjectId();
+
+ const response = await authedFetch(
+ `${baseUrl}/api/projects/${projectId}/sandbox_environments/`,
+ );
+
+ if (!response.ok) {
+ throw new HttpError(
+ response.status,
+ response.statusText,
+ "Failed to fetch sandbox environments",
+ );
+ }
+
+ const data = await parseJsonResponse<{ results?: SandboxEnvironment[] }>(
+ response,
+ );
+ return data.results ?? [];
+}
+
export async function getIntegrations(): Promise {
const baseUrl = getBaseUrl();
const projectId = getProjectId();
diff --git a/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx b/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx
new file mode 100644
index 0000000000..09b0531b6c
--- /dev/null
+++ b/apps/mobile/src/features/tasks/components/CustomImageBadge.test.tsx
@@ -0,0 +1,147 @@
+import { QueryClient, QueryClientProvider } from "@tanstack/react-query";
+import { createElement } from "react";
+import { act, create } from "react-test-renderer";
+import { beforeEach, describe, expect, it, vi } from "vitest";
+import type { Task, TaskRun } from "../types";
+
+const { mockUseAuthStore, mockGetImages, mockGetEnvironments } = vi.hoisted(
+ () => ({
+ mockUseAuthStore: vi.fn(),
+ mockGetImages: vi.fn(),
+ mockGetEnvironments: vi.fn(),
+ }),
+);
+
+vi.mock("@/features/auth", () => ({ useAuthStore: mockUseAuthStore }));
+
+vi.mock("../api", () => ({
+ getSandboxCustomImages: mockGetImages,
+ getSandboxEnvironments: mockGetEnvironments,
+}));
+
+vi.mock("phosphor-react-native", () => ({
+ Cube: (props: Record) => createElement("Cube", props),
+}));
+
+vi.mock("@/lib/theme", () => ({
+ toRgba: (hex: string, alpha: number) => `${hex}/${alpha}`,
+}));
+
+import { CustomImageBadge } from "./CustomImageBadge";
+
+function makeTask(run: Partial | undefined): Task {
+ return {
+ id: "task-1",
+ task_number: 1,
+ slug: "task-1",
+ title: "Task",
+ description: "",
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ origin_product: "user_created",
+ latest_run: run
+ ? ({
+ id: "run-1",
+ task: "task-1",
+ team: 1,
+ branch: null,
+ status: "completed",
+ log_url: "",
+ error_message: null,
+ output: null,
+ state: {},
+ created_at: "2026-01-01T00:00:00Z",
+ updated_at: "2026-01-01T00:00:00Z",
+ completed_at: null,
+ ...run,
+ } as TaskRun)
+ : undefined,
+ };
+}
+
+async function render(task: Task) {
+ const queryClient = new QueryClient({
+ defaultOptions: { queries: { retry: false, gcTime: 0 } },
+ });
+ let renderer: ReturnType | null = null;
+ await act(async () => {
+ renderer = create(
+ createElement(
+ QueryClientProvider,
+ { client: queryClient },
+ createElement(CustomImageBadge, { task }),
+ ),
+ );
+ });
+ // Flush pending react-query resolutions so a resolved image name renders.
+ await act(async () => {
+ await Promise.resolve();
+ await new Promise((resolve) => setTimeout(resolve, 0));
+ });
+ if (!renderer) throw new Error("Renderer not created");
+ return renderer as ReturnType;
+}
+
+function label(renderer: ReturnType): string | undefined {
+ const node = renderer.root.findAll(
+ (n) => typeof n.props?.accessibilityLabel === "string",
+ )[0];
+ return node?.props.accessibilityLabel as string | undefined;
+}
+
+describe("CustomImageBadge", () => {
+ beforeEach(() => {
+ mockUseAuthStore.mockReturnValue({
+ projectId: 1,
+ oauthAccessToken: "token",
+ });
+ mockGetImages.mockReset();
+ mockGetEnvironments.mockReset();
+ mockGetImages.mockResolvedValue([]);
+ mockGetEnvironments.mockResolvedValue([]);
+ });
+
+ it("renders nothing for a local run", async () => {
+ const r = await render(
+ makeTask({ environment: "local", state: { custom_image_id: "img-1" } }),
+ );
+ expect(r.toJSON()).toBeNull();
+ expect(mockGetImages).not.toHaveBeenCalled();
+ });
+
+ it("renders nothing for a cloud run without a custom image id", async () => {
+ const r = await render(makeTask({ environment: "cloud", state: {} }));
+ expect(r.toJSON()).toBeNull();
+ expect(mockGetImages).not.toHaveBeenCalled();
+ });
+
+ it("resolves the image name from a direct custom_image_id", async () => {
+ mockGetImages.mockResolvedValue([{ id: "img-1", name: "My Image" }]);
+ const r = await render(
+ makeTask({ environment: "cloud", state: { custom_image_id: "img-1" } }),
+ );
+ expect(label(r)).toBe('Runs on custom base image "My Image"');
+ });
+
+ it("resolves the image name via sandbox_environment_id", async () => {
+ mockGetEnvironments.mockResolvedValue([
+ { id: "env-1", custom_image_id: "img-2", custom_image_name: null },
+ ]);
+ mockGetImages.mockResolvedValue([{ id: "img-2", name: "Env Image" }]);
+ const r = await render(
+ makeTask({
+ environment: "cloud",
+ state: { sandbox_environment_id: "env-1" },
+ }),
+ );
+ expect(label(r)).toBe('Runs on custom base image "Env Image"');
+ });
+
+ it("renders nothing when the image cannot be resolved", async () => {
+ mockGetImages.mockResolvedValue([{ id: "other", name: "Other" }]);
+ const r = await render(
+ makeTask({ environment: "cloud", state: { custom_image_id: "missing" } }),
+ );
+ expect(r.toJSON()).toBeNull();
+ });
+});
diff --git a/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx b/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx
new file mode 100644
index 0000000000..2e382c228b
--- /dev/null
+++ b/apps/mobile/src/features/tasks/components/CustomImageBadge.tsx
@@ -0,0 +1,52 @@
+import { Text } from "@components/text";
+import { Cube } from "phosphor-react-native";
+import { View } from "react-native";
+import { toRgba } from "@/lib/theme";
+import { useCustomImageName } from "../hooks/useCustomImageName";
+import type { Task } from "../types";
+
+// Theme tokens have no violet; a fixed Radix violet-9 mirrors the desktop
+// custom-image badge and reads well in both light and dark.
+const VIOLET = "#6e56cf";
+
+export function CustomImageBadge({ task }: { task: Task }) {
+ const run = task.latest_run;
+ const state = run?.state as
+ | { custom_image_id?: unknown; sandbox_environment_id?: unknown }
+ | undefined;
+ const customImageId =
+ typeof state?.custom_image_id === "string" ? state.custom_image_id : null;
+ const sandboxEnvironmentId =
+ typeof state?.sandbox_environment_id === "string"
+ ? state.sandbox_environment_id
+ : null;
+
+ const imageName = useCustomImageName({
+ customImageId,
+ sandboxEnvironmentId,
+ enabled: run?.environment === "cloud",
+ });
+
+ if (!imageName) return null;
+
+ return (
+
+
+
+ {imageName}
+
+
+ );
+}
diff --git a/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts b/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts
new file mode 100644
index 0000000000..a634ecd5e6
--- /dev/null
+++ b/apps/mobile/src/features/tasks/hooks/useCustomImageName.ts
@@ -0,0 +1,55 @@
+import { useQuery } from "@tanstack/react-query";
+import { useAuthStore } from "@/features/auth";
+import { getSandboxCustomImages, getSandboxEnvironments } from "../api";
+
+export const sandboxKeys = {
+ customImages: () => ["sandbox-custom-images"] as const,
+ environments: () => ["sandbox-environments"] as const,
+};
+
+interface UseCustomImageNameArgs {
+ customImageId: string | null;
+ sandboxEnvironmentId: string | null;
+ enabled: boolean;
+}
+
+// Returns null while loading, when the fetch fails (custom images disabled), or
+// when the image can't be resolved — in every case the badge renders nothing.
+export function useCustomImageName({
+ customImageId,
+ sandboxEnvironmentId,
+ enabled,
+}: UseCustomImageNameArgs): string | null {
+ const { projectId, oauthAccessToken } = useAuthStore();
+ const canQuery = enabled && !!projectId && !!oauthAccessToken;
+ const hasImageRef = !!customImageId || !!sandboxEnvironmentId;
+
+ const imagesQuery = useQuery({
+ queryKey: sandboxKeys.customImages(),
+ queryFn: getSandboxCustomImages,
+ enabled: canQuery && hasImageRef,
+ staleTime: 60_000,
+ retry: 0,
+ });
+
+ const environmentsQuery = useQuery({
+ queryKey: sandboxKeys.environments(),
+ queryFn: getSandboxEnvironments,
+ enabled: canQuery && !!sandboxEnvironmentId,
+ staleTime: 60_000,
+ retry: 0,
+ });
+
+ const environment = sandboxEnvironmentId
+ ? environmentsQuery.data?.find((env) => env.id === sandboxEnvironmentId)
+ : undefined;
+
+ const imageId = customImageId ?? environment?.custom_image_id ?? null;
+ if (!imageId) return null;
+
+ return (
+ imagesQuery.data?.find((image) => image.id === imageId)?.name ??
+ environment?.custom_image_name ??
+ null
+ );
+}
diff --git a/packages/agent/src/adapters/local-tools/tools/speak.ts b/packages/agent/src/adapters/local-tools/tools/speak.ts
index 83ef123eaf..44f31320a0 100644
--- a/packages/agent/src/adapters/local-tools/tools/speak.ts
+++ b/packages/agent/src/adapters/local-tools/tools/speak.ts
@@ -48,10 +48,11 @@ export const SPEAK_TOOL_DESCRIPTION =
* speakers, so it just acknowledges. The desktop renderer observes the
* surfaced `tool_call` (carrying `text`/`needsUser` in its rawInput) and routes
* it to the speech queue, exactly like completion/permission notifications are
- * pure side effects off the event stream. Gated on `spokenNarration`: local
- * sessions pass the user's setting at session start, while cloud sessions
- * resolve it to true at the adapter (the sandbox can't know which clients are
- * listening, so cloud emits always and consumers gate playback).
+ * pure side effects off the event stream. Gated on `spokenNarration`, which is
+ * strictly opt-in (see `resolveSpokenNarration`): the desktop passes it true
+ * only when the feature flag and the user's setting are both enabled. Headless
+ * cloud runs (Slack threads, Signals scouts) never enable it, so the tool and
+ * its instructions never load and never cost tokens there.
*/
export const speakTool = defineLocalTool({
name: SPEAK_TOOL_NAME,
diff --git a/packages/agent/src/adapters/session-meta.test.ts b/packages/agent/src/adapters/session-meta.test.ts
index e65730aa00..b3492eece0 100644
--- a/packages/agent/src/adapters/session-meta.test.ts
+++ b/packages/agent/src/adapters/session-meta.test.ts
@@ -6,25 +6,18 @@ describe("resolveSpokenNarration", () => {
vi.unstubAllEnvs();
});
+ // Narration is strictly opt-in: only an explicit `spokenNarration: true`
+ // enables it. Cloud/sandbox runs no longer default on, so headless runs
+ // (Slack threads, Signals scouts) never load the tool or its instructions.
it.each([
{
- name: "explicit true on local",
- meta: { environment: "local" as const, spokenNarration: true },
+ name: "explicit true",
+ meta: { spokenNarration: true },
expected: true,
},
{
- name: "explicit false on cloud",
- meta: { environment: "cloud" as const, spokenNarration: false },
- expected: false,
- },
- {
- name: "cloud default",
- meta: { environment: "cloud" as const },
- expected: true,
- },
- {
- name: "local default",
- meta: { environment: "local" as const },
+ name: "explicit false",
+ meta: { spokenNarration: false },
expected: false,
},
{ name: "no meta", meta: undefined, expected: false },
@@ -35,20 +28,20 @@ describe("resolveSpokenNarration", () => {
});
it.each([
- { name: "no meta", meta: undefined, expected: true },
- { name: "empty meta", meta: {}, expected: true },
+ { name: "no meta", meta: undefined, expected: false },
+ { name: "empty meta", meta: {}, expected: false },
{
- name: "explicit false",
- meta: { spokenNarration: false },
- expected: false,
+ name: "explicit true",
+ meta: { spokenNarration: true },
+ expected: true,
},
{
- name: "explicit local environment",
- meta: { environment: "local" as const },
+ name: "explicit false",
+ meta: { spokenNarration: false },
expected: false,
},
])(
- "resolves $name to $expected in a sandbox without an environment tag",
+ "resolves $name to $expected in a sandbox (no default-on)",
({ meta, expected }) => {
vi.stubEnv("IS_SANDBOX", "1");
expect(resolveSpokenNarration(meta)).toBe(expected);
diff --git a/packages/agent/src/adapters/session-meta.ts b/packages/agent/src/adapters/session-meta.ts
index f70de89744..a4ccf9fdde 100644
--- a/packages/agent/src/adapters/session-meta.ts
+++ b/packages/agent/src/adapters/session-meta.ts
@@ -1,5 +1,3 @@
-import { isCloudRun } from "../utils/common";
-
/** Minimal shape needed to resolve the effective task id from session meta. */
interface TaskIdSource {
taskId?: string;
@@ -19,18 +17,21 @@ export function resolveTaskId(
/** Minimal shape needed to resolve spoken narration from session meta. */
interface SpokenNarrationSource {
- environment?: "local" | "cloud";
spokenNarration?: boolean;
}
/**
- * An explicit setting wins; otherwise cloud runs (including sandbox runs
- * detected via `IS_SANDBOX`) default to on because the sandbox can't know
- * which clients are listening, so consumers gate playback. Local runs stay
- * silent. Shared by the Claude and Codex adapters.
+ * Spoken narration is strictly opt-in: it is on only when a caller explicitly
+ * sets `spokenNarration` true at session start. The desktop is the only client
+ * that can play audio and the only place that knows the feature flag and the
+ * user's setting, so it computes that boolean and passes it. Everything else
+ * (headless cloud runs like Slack threads and Signals scouts, sandboxes, local
+ * runs without the setting) stays silent, so the `speak` tool and its
+ * instructions never load and never cost tokens where nothing is listening.
+ * Shared by the Claude and Codex adapters.
*/
export function resolveSpokenNarration(
meta: SpokenNarrationSource | undefined,
): boolean {
- return meta?.spokenNarration ?? isCloudRun(meta);
+ return meta?.spokenNarration === true;
}
diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts
index f96a13398d..096826e4fb 100644
--- a/packages/core/src/sessions/sessionService.ts
+++ b/packages/core/src/sessions/sessionService.ts
@@ -336,6 +336,7 @@ export interface SessionServiceDeps {
rtkEnabledLocal?: boolean;
rtkEnabledCloud?: boolean;
spokenNotifications?: boolean;
+ spokenNarrationEnabled?: boolean;
};
usageLimit: { show: (...args: any[]) => any };
readonly addDirectoryDialog: { open: boolean };
@@ -1087,14 +1088,14 @@ export class SessionService {
this.d.log.warn("Failed to verify workspace", { taskId, err });
});
- const { customInstructions, rtkEnabledLocal, spokenNotifications } =
+ const { customInstructions, rtkEnabledLocal, spokenNarrationEnabled } =
this.d.settings;
const result = await this.d.trpc.agent.reconnect.mutate({
taskId,
taskRunId,
repoPath,
rtkEnabled: rtkEnabledLocal,
- spokenNarration: spokenNotifications === true,
+ spokenNarration: spokenNarrationEnabled === true,
apiHost: auth.apiHost,
projectId: auth.projectId,
logUrl,
@@ -1417,7 +1418,7 @@ export class SessionService {
const {
customInstructions: startCustomInstructions,
rtkEnabledLocal,
- spokenNotifications,
+ spokenNarrationEnabled,
} = this.d.settings;
const preferredModel = model ?? this.d.DEFAULT_GATEWAY_MODEL;
const result = await this.d.trpc.agent.start.mutate({
@@ -1430,7 +1431,7 @@ export class SessionService {
adapter,
customInstructions: startCustomInstructions || undefined,
rtkEnabled: rtkEnabledLocal,
- spokenNarration: spokenNotifications === true,
+ spokenNarration: spokenNarrationEnabled === true,
effort: effortLevelSchema.safeParse(reasoningLevel).success
? (reasoningLevel as EffortLevel)
: undefined,
diff --git a/packages/ui/src/features/inbox/components/InboxSearchFilterBar.tsx b/packages/ui/src/features/inbox/components/InboxSearchFilterBar.tsx
index 5fff5f42c2..5f49cdc9f7 100644
--- a/packages/ui/src/features/inbox/components/InboxSearchFilterBar.tsx
+++ b/packages/ui/src/features/inbox/components/InboxSearchFilterBar.tsx
@@ -40,8 +40,14 @@ export function InboxSearchFilterBar({
const toggleSourceProduct = useInboxSignalsFilterStore(
(s) => s.toggleSourceProduct,
);
+ const clearSourceProductFilter = useInboxSignalsFilterStore(
+ (s) => s.clearSourceProductFilter,
+ );
const priorityFilter = useInboxSignalsFilterStore((s) => s.priorityFilter);
const togglePriority = useInboxSignalsFilterStore((s) => s.togglePriority);
+ const setPriorityFilter = useInboxSignalsFilterStore(
+ (s) => s.setPriorityFilter,
+ );
const activeSort = INBOX_SORT_OPTIONS.find(
(option) =>
@@ -73,6 +79,10 @@ export function InboxSearchFilterBar({
active={sourceProductFilter.length > 0}
>
+
{INBOX_SOURCE_OPTIONS.map((option) => {
const isActive = sourceProductFilter.includes(option.value);
return (
@@ -132,6 +142,10 @@ export function InboxSearchFilterBar({
active={priorityFilter.length > 0}
>
+ setPriorityFilter([])}
+ />
{INBOX_PRIORITY_OPTIONS.map((option) => {
const isActive = priorityFilter.includes(option.value);
return (
@@ -160,6 +174,23 @@ export function InboxSearchFilterBar({
);
}
+function InboxFilterAnyItem({
+ active,
+ onClick,
+}: {
+ active: boolean;
+ onClick: () => void;
+}) {
+ return (
+
+ );
+}
+
function InboxFilterPopover({
label,
value,
diff --git a/packages/ui/src/features/inbox/stores/inboxSignalsFilterStore.test.ts b/packages/ui/src/features/inbox/stores/inboxSignalsFilterStore.test.ts
index 64d14bd553..25bf6a9f82 100644
--- a/packages/ui/src/features/inbox/stores/inboxSignalsFilterStore.test.ts
+++ b/packages/ui/src/features/inbox/stores/inboxSignalsFilterStore.test.ts
@@ -71,6 +71,34 @@ describe("inboxSignalsFilterStore", () => {
]);
});
+ it("setPriorityFilter resets priorities back to Any (empty)", () => {
+ useInboxSignalsFilterStore.getState().setPriorityFilter(["P0", "P1"]);
+
+ useInboxSignalsFilterStore.getState().setPriorityFilter([]);
+
+ expect(useInboxSignalsFilterStore.getState().priorityFilter).toEqual([]);
+ });
+
+ it("clearSourceProductFilter resets sources back to Any (empty)", () => {
+ useInboxSignalsFilterStore.getState().toggleSourceProduct("github");
+ useInboxSignalsFilterStore.getState().toggleSourceProduct("linear");
+
+ useInboxSignalsFilterStore.getState().clearSourceProductFilter();
+
+ expect(useInboxSignalsFilterStore.getState().sourceProductFilter).toEqual(
+ [],
+ );
+ });
+
+ it("toggling off the last source is equivalent to Any (empty)", () => {
+ useInboxSignalsFilterStore.getState().toggleSourceProduct("github");
+ useInboxSignalsFilterStore.getState().toggleSourceProduct("github");
+
+ expect(useInboxSignalsFilterStore.getState().sourceProductFilter).toEqual(
+ [],
+ );
+ });
+
it("setPriorityFilter de-duplicates priorities", () => {
useInboxSignalsFilterStore.getState().setPriorityFilter(["P0", "P1", "P0"]);
diff --git a/packages/ui/src/features/inbox/stores/inboxSignalsFilterStore.ts b/packages/ui/src/features/inbox/stores/inboxSignalsFilterStore.ts
index 33f25ef3bb..921ab26d71 100644
--- a/packages/ui/src/features/inbox/stores/inboxSignalsFilterStore.ts
+++ b/packages/ui/src/features/inbox/stores/inboxSignalsFilterStore.ts
@@ -31,6 +31,8 @@ interface InboxSignalsFilterActions {
toggleSourceProduct: (source: SourceProduct) => void;
togglePriority: (priority: SignalReportPriority) => void;
setPriorityFilter: (priorities: SignalReportPriority[]) => void;
+ /** Clear the source filter back to "Any" (empty = all sources). */
+ clearSourceProductFilter: () => void;
/** Reset all filters when a deep link arrives so the linked report isn't hidden. */
resetFilters: () => void;
}
@@ -74,6 +76,7 @@ export const useInboxSignalsFilterStore = create()(
set({
priorityFilter: Array.from(new Set(priorities)),
}),
+ clearSourceProductFilter: () => set({ sourceProductFilter: [] }),
resetFilters: () =>
set({
searchQuery: "",
diff --git a/packages/ui/src/features/sessions/sessionServiceHost.test.ts b/packages/ui/src/features/sessions/sessionServiceHost.test.ts
index c17203b38b..0c84840a5e 100644
--- a/packages/ui/src/features/sessions/sessionServiceHost.test.ts
+++ b/packages/ui/src/features/sessions/sessionServiceHost.test.ts
@@ -236,8 +236,14 @@ const mockSpeechNotifier = vi.hoisted(() => ({
speak: vi.fn(),
}));
+const mockFeatureFlags = vi.hoisted(() => ({
+ isEnabled: vi.fn(() => false),
+ onFlagsLoaded: vi.fn(() => vi.fn()),
+}));
+
const mockSettingsState = vi.hoisted(() => ({
customInstructions: "",
+ spokenNotifications: false,
syncCustomInstructionsFromFile: false,
syncedCustomInstructions: null as {
path: string;
@@ -314,6 +320,9 @@ vi.mock("@posthog/di/container", () => ({
if (typeof token === "function" && token.name === "SpeechNotifier") {
return mockSpeechNotifier;
}
+ if (token === Symbol.for("posthog.ui.featureFlags")) {
+ return mockFeatureFlags;
+ }
throw new Error(`resolveService: unmocked token ${String(token)}`);
},
}));
@@ -382,7 +391,11 @@ vi.mock("@posthog/core/sessions/sessionEvents", async () => {
});
import { toast } from "@posthog/ui/primitives/toast";
-import { getSessionService, resetSessionService } from "./sessionServiceHost";
+import {
+ getSessionService,
+ resetSessionService,
+ shouldEnableSpokenNarration,
+} from "./sessionServiceHost";
// --- Test Fixtures ---
@@ -426,6 +439,8 @@ describe("SessionService", () => {
mockConvertStoredEntriesToEvents.mockImplementation(() => []);
resetSessionService();
mockSettingsState.customInstructions = "";
+ mockSettingsState.spokenNotifications = false;
+ mockFeatureFlags.isEnabled.mockReturnValue(false);
mockSettingsState.syncCustomInstructionsFromFile = false;
mockSettingsState.syncedCustomInstructions = null;
mockGetIsOnline.mockReturnValue(true);
@@ -514,6 +529,42 @@ describe("SessionService", () => {
});
});
+ describe("spoken narration availability", () => {
+ it.each([
+ {
+ userOptedIn: true,
+ flagEnabled: true,
+ isDevelopment: false,
+ expected: true,
+ },
+ {
+ userOptedIn: false,
+ flagEnabled: true,
+ isDevelopment: false,
+ expected: false,
+ },
+ {
+ userOptedIn: true,
+ flagEnabled: false,
+ isDevelopment: false,
+ expected: false,
+ },
+ {
+ userOptedIn: true,
+ flagEnabled: false,
+ isDevelopment: true,
+ expected: true,
+ },
+ ])(
+ "returns $expected for opt-in=$userOptedIn flag=$flagEnabled dev=$isDevelopment",
+ ({ userOptedIn, flagEnabled, isDevelopment, expected }) => {
+ expect(
+ shouldEnableSpokenNarration(userOptedIn, flagEnabled, isDevelopment),
+ ).toBe(expected);
+ },
+ );
+ });
+
describe("connectToTask", () => {
it("skips local connection for cloud runs", async () => {
const service = getSessionService();
@@ -5036,6 +5087,8 @@ describe("SessionService", () => {
it("preserves codex runtime selection when resuming a terminal cloud run", async () => {
const service = getSessionService();
+ mockSettingsState.spokenNotifications = true;
+ mockFeatureFlags.isEnabled.mockReturnValue(true);
mockSessionStoreSetters.getSessionByTaskId.mockReturnValue(
createMockSession({
isCloud: true,
diff --git a/packages/ui/src/features/sessions/sessionServiceHost.ts b/packages/ui/src/features/sessions/sessionServiceHost.ts
index 44bfc7a9f7..9811a82430 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";
@@ -53,6 +58,14 @@ export { SessionService };
const log = logger.scope("session-service");
+export function shouldEnableSpokenNarration(
+ userOptedIn: boolean,
+ flagEnabled: boolean,
+ isDevelopment: boolean,
+): boolean {
+ return userOptedIn && (flagEnabled || isDevelopment);
+}
+
function hostClient(): HostTrpcClient {
return resolveService(HOST_TRPC_CLIENT);
}
@@ -117,6 +130,13 @@ function buildSessionServiceDeps(): SessionServiceDeps {
return {
...state,
customInstructions: getEffectiveCustomInstructions(state),
+ spokenNarrationEnabled: shouldEnableSpokenNarration(
+ state.spokenNotifications,
+ resolveService(FEATURE_FLAGS).isEnabled(
+ SPOKEN_NARRATION_FLAG,
+ ),
+ import.meta.env.DEV,
+ ),
};
},
usageLimit: {
diff --git a/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts
index 9234cd7f6e..5d8eeab508 100644
--- a/packages/workspace-server/src/services/agent/schemas.ts
+++ b/packages/workspace-server/src/services/agent/schemas.ts
@@ -93,8 +93,9 @@ export const startSessionInput = z.object({
rtkEnabled: z.boolean().optional(),
/**
* The user's spoken-narration setting at session start. Gates the agent's
- * speak tool and its prompt instructions; when absent the adapter defaults
- * by environment (cloud on, local off).
+ * speak tool and its prompt instructions. Strictly opt-in: only the desktop
+ * sets it true (feature flag + setting); when absent the adapter leaves
+ * narration off, so headless runs never load the tool.
*/
spokenNarration: z.boolean().optional(),
});