Skip to content

Commit 8ca9e4b

Browse files
authored
fix(notifications): mute backstop narration while viewing the task
Consecutive permission requests each spoke "needs your input" even while the user was staring at the chat clicking Approve: needs_input bypassed focus routing unconditionally. Tag each speech request with its source — the agent's intentional speak tool call vs the deterministic turn/permission backstop. Backstop lines never play when focus routing says "suppress" (app focused, viewing that task); agent lines keep current behavior, including the needs_input focus bypass. A prompt on a task you're not viewing still speaks. Also adds the enqueueSpeech fake missing from the cloud-notification test harness since the speak feature landed. Generated-By: PostHog Code Task-Id: 6912b099-cb75-4f41-a337-1d2275c4be66
1 parent 243a9d3 commit 8ca9e4b

7 files changed

Lines changed: 157 additions & 20 deletions

File tree

Lines changed: 59 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,59 @@
1+
# Quiet backstop narration when viewing the task
2+
3+
Date: 2026-07-02
4+
Status: approved
5+
6+
## Problem
7+
8+
Every permission request fires the deterministic speech backstop
9+
(`speakDeterministic(taskRunId, session, "needs_input")` in
10+
`packages/core/src/sessions/sessionService.ts`). `shouldSpeak` in
11+
`packages/ui/src/features/notifications/speechRouting.ts` lets every
12+
`needs_input` line bypass focus routing (`if (kind === "needs_input") return
13+
true`), and the queue treats them as priority lines that are never coalesced.
14+
Result: consecutive permission prompts each speak "needs your input" while the
15+
user is already looking at the chat approving them.
16+
17+
## Design
18+
19+
Distinguish the two speakers sharing the speech pipe and gate them
20+
differently:
21+
22+
- `source: "agent"` — lines from the agent's `speak` tool call. Keep current
23+
behavior: `needs_input` always plays; `done`/`progress` follow
24+
`spokenFocusMode`.
25+
- `source: "backstop"` — deterministic lines ("finished", "needs your input")
26+
fired by turn-complete and permission events. Never play when the focus
27+
channel is `"suppress"` (app focused and the user is viewing that task).
28+
Otherwise follow `spokenFocusMode` as today.
29+
30+
`"suppress"` is per-task: a permission prompt on a task the user is *not*
31+
viewing (app focused, different tab) still speaks — that is the multi-agent
32+
case the voice exists for.
33+
34+
## Changes
35+
36+
1. `packages/ui/src/features/notifications/speechRouting.ts` — add
37+
`SpeechSource = "agent" | "backstop"`; `shouldSpeak(kind, source, channel,
38+
settings)`: backstop lines return `false` when `channel === "suppress"`;
39+
agent `needs_input` keeps its unconditional pass.
40+
2. `packages/ui/src/features/notifications/speechNotifier.ts` — add `source`
41+
to `SpeakRequest`, pass through to `shouldSpeak`.
42+
3. `packages/core/src/sessions/sessionService.ts` — add `source` to the
43+
`enqueueSpeech` dep signature; `speakDeterministic` sends `"backstop"`,
44+
the speak-tool handler sends `"agent"`.
45+
4. `packages/ui/src/features/sessions/sessionServiceHost.ts` — type flows
46+
through unchanged (verify).
47+
5. `packages/ui/src/features/notifications/speechRouting.test.ts` — cover the
48+
new matrix: backstop suppressed on `"suppress"`, backstop follows focus
49+
mode on `"toast"`/`"native"`, agent `needs_input` unaffected.
50+
51+
No settings UI change. No queue changes.
52+
53+
## Rejected alternatives
54+
55+
- Gate all `needs_input` by focus mode (no source flag): also mutes the
56+
agent's intentional "Hey Jon" lines.
57+
- Debounce/dedupe backstop lines in the queue: treats the symptom; focus
58+
suppression already kills repeats once the user is present, and serial
59+
prompts while away should each speak.

packages/core/src/sessions/cloudTaskUpdateNotifications.test.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -179,6 +179,7 @@ function createHarness() {
179179
sendUpdate: (update: CloudTaskUpdatePayload) => onUpdate?.(update),
180180
notifyPromptComplete,
181181
notifyPermissionRequest,
182+
enqueueSpeech,
182183
markActivity,
183184
};
184185
}
@@ -255,6 +256,9 @@ describe("cloud task update notifications", () => {
255256
TASK_ID,
256257
45_000,
257258
);
259+
expect(harness.enqueueSpeech).toHaveBeenCalledWith(
260+
expect.objectContaining({ kind: "done", source: "backstop" }),
261+
);
258262
expect(harness.markActivity).toHaveBeenCalledTimes(1);
259263
});
260264

@@ -271,6 +275,9 @@ describe("cloud task update notifications", () => {
271275

272276
snapshot();
273277
expect(harness.notifyPermissionRequest).toHaveBeenCalledTimes(1);
278+
expect(harness.enqueueSpeech).toHaveBeenCalledWith(
279+
expect.objectContaining({ kind: "needs_input", source: "backstop" }),
280+
);
274281

275282
snapshot();
276283
snapshot();

packages/core/src/sessions/sessionService.ts

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -40,7 +40,7 @@ import {
4040
isTerminalStatus,
4141
type Task,
4242
} from "@posthog/shared/domain-types";
43-
import type { SpeechKind } from "../speech/identifiers";
43+
import type { SpeechKind, SpeechSource } from "../speech/identifiers";
4444
import {
4545
isNotification,
4646
POSTHOG_NOTIFICATIONS,
@@ -288,6 +288,7 @@ export interface SessionServiceDeps {
288288
taskTitle: string;
289289
taskId?: string;
290290
kind: SpeechKind;
291+
source: SpeechSource;
291292
addressByName?: boolean;
292293
}) => void;
293294
getIsOnline: () => boolean;
@@ -1960,6 +1961,7 @@ export class SessionService {
19601961
taskTitle: session.taskTitle,
19611962
taskId: session.taskId,
19621963
kind,
1964+
source: "backstop",
19631965
addressByName: false,
19641966
});
19651967
}
@@ -2116,6 +2118,7 @@ export class SessionService {
21162118
taskTitle: session.taskTitle,
21172119
taskId: session.taskId,
21182120
kind: pending.kind,
2121+
source: "agent",
21192122
// Agent-authored line: allowed to address the user by name.
21202123
addressByName: true,
21212124
});

packages/core/src/speech/identifiers.ts

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,13 @@
11
/** Why the agent is speaking; drives priority, greeting, and per-kind gating. */
22
export type SpeechKind = "needs_input" | "done" | "progress";
33

4+
/**
5+
* Who authored the line: the agent's intentional `speak` tool call, or the
6+
* deterministic backstop fired by turn-complete / permission events. Backstop
7+
* lines are suppressed while the user is viewing the task; agent lines are not.
8+
*/
9+
export type SpeechSource = "agent" | "backstop";
10+
411
/** A narration request as it arrives from the agent's `speak` tool call. */
512
export interface SpeechRequest {
613
/** The message body the agent produced (no task prefix, no user name). */

packages/ui/src/features/notifications/speechNotifier.ts

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -11,11 +11,17 @@ import {
1111
SPEECH_NOTIFY_SETTINGS,
1212
} from "./identifiers";
1313
import { routeNotification } from "./routeNotification";
14-
import { type SpeechKind, shouldSpeak } from "./speechRouting";
14+
import {
15+
type SpeechKind,
16+
type SpeechSource,
17+
shouldSpeak,
18+
} from "./speechRouting";
1519

1620
export interface SpeakRequest {
1721
text: string;
1822
kind: SpeechKind;
23+
/** Agent `speak` tool call, or the deterministic turn/permission backstop. */
24+
source: SpeechSource;
1925
taskTitle: string;
2026
taskId?: string;
2127
/** Address the user by name ("Hey <name>,") — agent lines only, not backstop. */
@@ -49,7 +55,10 @@ export class SpeechNotifier {
4955
notificationTarget: target,
5056
});
5157

52-
if (!shouldSpeak(request.kind, channel, this.settings.get())) return;
58+
if (
59+
!shouldSpeak(request.kind, request.source, channel, this.settings.get())
60+
)
61+
return;
5362

5463
this.queue.enqueue({
5564
text: request.text,

packages/ui/src/features/notifications/speechRouting.test.ts

Lines changed: 53 additions & 10 deletions
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,7 @@ import type { NotificationChannel } from "./routeNotification";
33
import {
44
type SpeechGateSettings,
55
type SpeechKind,
6+
type SpeechSource,
67
shouldSpeak,
78
} from "./speechRouting";
89

@@ -17,7 +18,10 @@ const base: SpeechGateSettings = {
1718
describe("shouldSpeak", () => {
1819
it("is silent when the feature is disabled", () => {
1920
expect(
20-
shouldSpeak("needs_input", "native", { ...base, enabled: false }),
21+
shouldSpeak("needs_input", "agent", "native", {
22+
...base,
23+
enabled: false,
24+
}),
2125
).toBe(false);
2226
});
2327

@@ -26,13 +30,15 @@ describe("shouldSpeak", () => {
2630
["done", "completion"],
2731
["progress", "progress"],
2832
])("respects the per-kind toggle for %s", (kind, key) => {
29-
expect(shouldSpeak(kind, "native", { ...base, [key]: false })).toBe(false);
33+
expect(
34+
shouldSpeak(kind, "agent", "native", { ...base, [key]: false }),
35+
).toBe(false);
3036
});
3137

32-
it("always speaks needs-input regardless of focus", () => {
38+
it("always speaks agent needs-input regardless of focus", () => {
3339
for (const channel of ["suppress", "toast", "native"] as const) {
3440
expect(
35-
shouldSpeak("needs_input", channel, {
41+
shouldSpeak("needs_input", "agent", channel, {
3642
...base,
3743
focusMode: "app_unfocused",
3844
}),
@@ -44,26 +50,63 @@ describe("shouldSpeak", () => {
4450
["suppress", false],
4551
["toast", true],
4652
["native", true],
47-
])("unviewed_task: channel %s -> %s", (channel, expected) => {
53+
])(
54+
"backstop needs-input is silent only over the viewed task: channel %s -> %s",
55+
(channel, expected) => {
56+
expect(
57+
shouldSpeak("needs_input", "backstop", channel, {
58+
...base,
59+
focusMode: "app_unfocused",
60+
}),
61+
).toBe(expected);
62+
},
63+
);
64+
65+
it("backstop done never plays over the viewed task, even in always mode", () => {
4866
expect(
49-
shouldSpeak("done", channel, { ...base, focusMode: "unviewed_task" }),
50-
).toBe(expected);
67+
shouldSpeak("done", "backstop", "suppress", {
68+
...base,
69+
focusMode: "always",
70+
}),
71+
).toBe(false);
5172
});
5273

74+
it.each<[SpeechSource, NotificationChannel, boolean]>([
75+
["agent", "suppress", false],
76+
["agent", "toast", true],
77+
["agent", "native", true],
78+
["backstop", "suppress", false],
79+
["backstop", "toast", true],
80+
["backstop", "native", true],
81+
])(
82+
"unviewed_task: %s done on channel %s -> %s",
83+
(source, channel, expected) => {
84+
expect(
85+
shouldSpeak("done", source, channel, {
86+
...base,
87+
focusMode: "unviewed_task",
88+
}),
89+
).toBe(expected);
90+
},
91+
);
92+
5393
it.each<[NotificationChannel, boolean]>([
5494
["suppress", false],
5595
["toast", false],
5696
["native", true],
5797
])("app_unfocused: channel %s -> %s", (channel, expected) => {
5898
expect(
59-
shouldSpeak("done", channel, { ...base, focusMode: "app_unfocused" }),
99+
shouldSpeak("done", "agent", channel, {
100+
...base,
101+
focusMode: "app_unfocused",
102+
}),
60103
).toBe(expected);
61104
});
62105

63-
it("always mode speaks on every channel", () => {
106+
it("always mode speaks agent lines on every channel", () => {
64107
for (const channel of ["suppress", "toast", "native"] as const) {
65108
expect(
66-
shouldSpeak("done", channel, { ...base, focusMode: "always" }),
109+
shouldSpeak("done", "agent", channel, { ...base, focusMode: "always" }),
67110
).toBe(true);
68111
}
69112
});

packages/ui/src/features/notifications/speechRouting.ts

Lines changed: 16 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -1,8 +1,11 @@
1-
import type { SpeechKind } from "@posthog/core/speech/identifiers";
1+
import type {
2+
SpeechKind,
3+
SpeechSource,
4+
} from "@posthog/core/speech/identifiers";
25
import type { SpokenFocusMode } from "@posthog/ui/features/settings/settingsStore";
36
import type { NotificationChannel } from "./routeNotification";
47

5-
export type { SpeechKind };
8+
export type { SpeechKind, SpeechSource };
69

710
export interface SpeechGateSettings {
811
enabled: boolean;
@@ -13,15 +16,19 @@ export interface SpeechGateSettings {
1316
}
1417

1518
/**
16-
* Whether a spoken line should play, given the focus-routing channel (from
17-
* routeNotification) and the user's spoken-notification settings. Pure so the
18-
* policy is exhaustively unit-tested without the DI graph.
19+
* Whether a spoken line should play, given who authored it, the focus-routing
20+
* channel (from routeNotification) and the user's spoken-notification
21+
* settings. Pure so the policy is exhaustively unit-tested without the DI
22+
* graph.
1923
*
20-
* needs-input lines ignore focus mode entirely — a blocker is the whole point
21-
* of the feature, so it's never suppressed for being on screen.
24+
* Agent needs-input lines ignore focus mode entirely — a blocker is the whole
25+
* point of the feature. Backstop lines never play over the task the user is
26+
* already viewing: the permission dialog (or finished turn) is on screen, and
27+
* consecutive permission prompts would otherwise narrate every approval click.
2228
*/
2329
export function shouldSpeak(
2430
kind: SpeechKind,
31+
source: SpeechSource,
2532
channel: NotificationChannel,
2633
s: SpeechGateSettings,
2734
): boolean {
@@ -35,6 +42,8 @@ export function shouldSpeak(
3542
: s.progress;
3643
if (!kindEnabled) return false;
3744

45+
if (source === "backstop" && channel === "suppress") return false;
46+
3847
if (kind === "needs_input") return true;
3948

4049
switch (s.focusMode) {

0 commit comments

Comments
 (0)