Skip to content
Merged
Show file tree
Hide file tree
Changes from 3 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: 5 additions & 4 deletions packages/agent/src/adapters/local-tools/tools/speak.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
56 changes: 33 additions & 23 deletions packages/agent/src/adapters/session-meta.test.ts
Original file line number Diff line number Diff line change
@@ -1,30 +1,26 @@
import { afterEach, describe, expect, it, vi } from "vitest";
import { resolveSpokenNarration } from "./session-meta";
import {
resolveCloudSpokenNarration,
resolveSpokenNarration,
} from "./session-meta";

describe("resolveSpokenNarration", () => {
afterEach(() => {
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 },
Expand All @@ -35,23 +31,37 @@ 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);
},
);
});

describe("resolveCloudSpokenNarration", () => {
it.each([
{ state: { spoken_narration: true }, expected: true },
{ state: { spoken_narration: false }, expected: false },
{ state: {}, expected: false },
{ state: undefined, expected: false },
])(
"resolves explicit cloud run opt-in to $expected",
({ state, expected }) => {
expect(resolveCloudSpokenNarration(state)).toBe(expected);
},
);
});
27 changes: 19 additions & 8 deletions packages/agent/src/adapters/session-meta.ts
Original file line number Diff line number Diff line change
@@ -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;
Expand All @@ -19,18 +17,31 @@ export function resolveTaskId(

/** Minimal shape needed to resolve spoken narration from session meta. */
interface SpokenNarrationSource {
environment?: "local" | "cloud";
spokenNarration?: boolean;
}

interface SpokenNarrationRunState {
spoken_narration?: unknown;
}

/**
* 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;
Comment thread
pauldambra marked this conversation as resolved.

Copy link
Copy Markdown
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Note

🤖 Automated comment by QA Swarm — not written by a human

[convergent: paul-reviewer + xp-reviewer] 🟠 HIGH

Strict opt-in currently makes narration local-only because interactive cloud runs have no backend-supported way to carry the desktop flag + user opt-in decision. Supporting cloud narration requires a coordinated server contract or another explicit transport; otherwise this PR intentionally leaves cloud runs silent.

}

export function resolveCloudSpokenNarration(
state: SpokenNarrationRunState | undefined,
): boolean {
return state?.spoken_narration === true;
}
2 changes: 2 additions & 0 deletions packages/agent/src/server/agent-server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ import {
classifyAgentError,
isPromptTooLongError,
} from "../adapters/error-classification";
import { resolveCloudSpokenNarration } from "../adapters/session-meta";
import {
SIGNED_COMMIT_QUALIFIED_TOOL_NAME,
SIGNED_MERGE_QUALIFIED_TOOL_NAME,
Expand Down Expand Up @@ -1497,6 +1498,7 @@ export class AgentServer {
allowedDomains: this.config.allowedDomains,
jsonSchema: preTask?.json_schema ?? null,
permissionMode: initialPermissionMode,
spokenNarration: resolveCloudSpokenNarration(runState),
...(this.config.baseBranch && { baseBranch: this.config.baseBranch }),
...this.buildClaudeCodeSessionMeta(runtimeAdapter),
};
Expand Down
2 changes: 2 additions & 0 deletions packages/api-client/src/posthog-client.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,7 @@ describe("PostHogAPIClient", () => {
adapter: "codex",
model: "gpt-5.4",
reasoningLevel: "high",
spokenNarration: true,
});

expect(post).toHaveBeenCalledWith(
Expand All @@ -37,6 +38,7 @@ describe("PostHogAPIClient", () => {
runtime_adapter: "codex",
model: "gpt-5.4",
reasoning_effort: "high",
spoken_narration: true,
}),
}),
);
Expand Down
5 changes: 5 additions & 0 deletions packages/api-client/src/posthog-client.ts
Original file line number Diff line number Diff line change
Expand Up @@ -613,6 +613,8 @@ interface CloudRunOptions {
autoPublish?: boolean;
/** Only false is sent: opts the run out of rtk command-output compression. */
rtkEnabled?: boolean;
/** Explicit desktop opt-in; omitted for headless/background callers. */
spokenNarration?: boolean;
runSource?: CloudRunSource;
signalReportId?: string;
initialPermissionMode?: ExecutionMode;
Expand Down Expand Up @@ -708,6 +710,9 @@ function buildCloudRunRequestBody(
if (options?.rtkEnabled === false) {
body.rtk_enabled = false;
}
if (options?.spokenNarration !== undefined) {
Comment thread
pauldambra marked this conversation as resolved.
Outdated
body.spoken_narration = options.spokenNarration;
}
if (options?.runSource) {
body.run_source = options.runSource;
}
Expand Down
10 changes: 6 additions & 4 deletions packages/core/src/sessions/sessionService.ts
Original file line number Diff line number Diff line change
Expand Up @@ -336,6 +336,7 @@ export interface SessionServiceDeps {
rtkEnabledLocal?: boolean;
rtkEnabledCloud?: boolean;
spokenNotifications?: boolean;
spokenNarrationEnabled?: boolean;
};
usageLimit: { show: (...args: any[]) => any };
readonly addDirectoryDialog: { open: boolean };
Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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({
Expand All @@ -1430,7 +1431,7 @@ export class SessionService {
adapter,
customInstructions: startCustomInstructions || undefined,
Comment thread
pauldambra marked this conversation as resolved.
rtkEnabled: rtkEnabledLocal,
spokenNarration: spokenNotifications === true,
spokenNarration: spokenNarrationEnabled === true,
effort: effortLevelSchema.safeParse(reasoningLevel).success
? (reasoningLevel as EffortLevel)
: undefined,
Expand Down Expand Up @@ -3407,6 +3408,7 @@ export class SessionService {
prAuthorshipMode,
autoPublish: previousState.auto_publish === true || undefined,
rtkEnabled: this.d.settings.rtkEnabledCloud,
spokenNarration: this.d.settings.spokenNarrationEnabled === true,
runSource: getCloudRunSource(previousState),
signalReportId:
typeof previousState.signal_report_id === "string"
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/task-detail/taskCreationApiClient.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ export interface CreateTaskRunClientOptions {
prAuthorshipMode?: PrAuthorshipMode;
autoPublish?: boolean;
rtkEnabled?: boolean;
spokenNarration?: boolean;
runSource?: CloudRunSource;
signalReportId?: string;
initialPermissionMode?: string;
Expand Down
2 changes: 2 additions & 0 deletions packages/core/src/task-detail/taskCreationSaga.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -154,6 +154,7 @@ describe("TaskCreationSaga", () => {
reasoningLevel: "high",
cloudAutoPublish: true,
cloudRtkEnabled: false,
spokenNarration: true,
});

expect(result.success).toBe(true);
Expand All @@ -172,6 +173,7 @@ describe("TaskCreationSaga", () => {
prAuthorshipMode: "user",
autoPublish: true,
rtkEnabled: false,
spokenNarration: true,
runSource: "manual",
signalReportId: undefined,
initialPermissionMode: "auto",
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/task-detail/taskCreationSaga.ts
Original file line number Diff line number Diff line change
Expand Up @@ -404,6 +404,7 @@ export class TaskCreationSaga extends Saga<
prAuthorshipMode,
autoPublish: input.cloudAutoPublish,
rtkEnabled: input.cloudRtkEnabled,
spokenNarration: input.spokenNarration,
runSource: input.cloudRunSource ?? "manual",
signalReportId: input.signalReportId,
homeQuickAction: input.homeQuickActionLabel,
Expand Down
15 changes: 15 additions & 0 deletions packages/core/src/task-detail/taskInput.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -27,4 +27,19 @@ describe("prepareTaskInput", () => {
});
expect(input.customInstructions).toBeUndefined();
});

it.each([
{ workspaceMode: "cloud" as const, expected: true },
{ workspaceMode: "local" as const, expected: undefined },
{ workspaceMode: "worktree" as const, expected: undefined },
])(
"passes spoken narration through only for cloud (%s)",
({ workspaceMode, expected }) => {
const input = prepareTaskInput("do the thing", [], {
workspaceMode,
spokenNarration: true,
});
expect(input.spokenNarration).toBe(expected);
},
);
});
2 changes: 2 additions & 0 deletions packages/core/src/task-detail/taskInput.ts
Original file line number Diff line number Diff line change
Expand Up @@ -32,6 +32,7 @@ export interface PrepareTaskInputOptions {
customInstructions?: string;
autoPublishCloudRuns?: boolean;
rtkEnabledCloud?: boolean;
spokenNarration?: boolean;
allowNoRepo?: boolean;
importedMcpServers?: CloudMcpServerImport[];
relayedMcpServers?: CloudMcpServerRelayDesignation[];
Expand Down Expand Up @@ -70,6 +71,7 @@ export function prepareTaskInput(
options.signalReportId && isCloud ? "signal_report" : undefined,
cloudAutoPublish: isCloud ? options.autoPublishCloudRuns : undefined,
cloudRtkEnabled: isCloud ? options.rtkEnabledCloud : undefined,
spokenNarration: isCloud ? options.spokenNarration : undefined,
signalReportId: options.signalReportId,
additionalDirectories: isCloud ? undefined : options.additionalDirectories,
channelContext: options.channelContext,
Expand Down
2 changes: 2 additions & 0 deletions packages/shared/src/task-creation-domain.ts
Original file line number Diff line number Diff line change
Expand Up @@ -52,6 +52,8 @@ export interface TaskCreationInput {
* meaningful: it opts the run out of the server-side default (enabled).
*/
cloudRtkEnabled?: boolean;
/** Explicit desktop opt-in for spoken narration in interactive cloud runs. */
spokenNarration?: boolean;
signalReportId?: string;
additionalDirectories?: string[];
/**
Expand Down
Loading
Loading