diff --git a/packages/agent/src/adapters/claude/claude-agent.ts b/packages/agent/src/adapters/claude/claude-agent.ts index 2ed43af846..6e3b1bae86 100644 --- a/packages/agent/src/adapters/claude/claude-agent.ts +++ b/packages/agent/src/adapters/claude/claude-agent.ts @@ -75,7 +75,7 @@ import { Logger } from "../../utils/logger"; import { Pushable } from "../../utils/streams"; import { BaseAcpAgent } from "../base-acp-agent"; import { LOCAL_TOOLS_MCP_NAME } from "../local-tools"; -import { resolveTaskId } from "../session-meta"; +import { resolveSpokenNarration, resolveTaskId } from "../session-meta"; import { buildBreakdown, emptyBaseline, @@ -1891,6 +1891,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { // needs so the session doesn't pin the whole meta object. const baseBranch = meta?.baseBranch; const environment = meta?.environment; + const spokenNarration = resolveSpokenNarration(meta); const buildInProcessMcpServers = (): Record< string, McpSdkServerConfigWithInstance @@ -1903,7 +1904,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent { taskRunId: meta?.taskRunId, baseBranch, }, - { environment }, + { environment, spokenNarration }, ); return server ? { [LOCAL_TOOLS_MCP_NAME]: server } : {}; }; @@ -1923,7 +1924,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent { ...initialInProcess, }; - const systemPrompt = buildSystemPrompt(meta?.systemPrompt); + const systemPrompt = buildSystemPrompt(meta?.systemPrompt, { + spokenNarration, + }); if (meta?.mcpToolApprovals) { setMcpToolApprovalStates(meta.mcpToolApprovals); diff --git a/packages/agent/src/adapters/claude/mcp/local-tools.test.ts b/packages/agent/src/adapters/claude/mcp/local-tools.test.ts index 062aede9fc..2525432245 100644 --- a/packages/agent/src/adapters/claude/mcp/local-tools.test.ts +++ b/packages/agent/src/adapters/claude/mcp/local-tools.test.ts @@ -20,10 +20,10 @@ describe("createLocalToolsMcpServer", () => { } }); - it("exposes only the always-on tools on a desktop run (no cloud-only tools)", async () => { + it("exposes speak on a desktop run with narration on (no cloud-only tools)", async () => { const server = createLocalToolsMcpServer( { cwd: "/repo", token: "ghs_x" }, - undefined, + { environment: "local", spokenNarration: true }, ); if (!server) { throw new Error("expected the local-tools server to be registered"); @@ -37,7 +37,6 @@ describe("createLocalToolsMcpServer", () => { const { tools } = await client.listTools(); const names = tools.map((t) => t.name); - // `speak` is always on (narration works on desktop and cloud alike). expect(names).toContain("speak"); // Signed-git tools are cloud-only and must not leak into a desktop run. expect(names).not.toContain("git_signed_commit"); @@ -45,6 +44,14 @@ describe("createLocalToolsMcpServer", () => { await client.close(); }); + it("registers no server on a desktop run with narration off (no tools pass their gate)", () => { + const server = createLocalToolsMcpServer( + { cwd: "/repo", token: "ghs_x" }, + undefined, + ); + expect(server).toBeUndefined(); + }); + it("exposes git_signed_commit over MCP in a cloud run with a token", async () => { const server = createLocalToolsMcpServer( { cwd: "/repo", token: "ghs_x" }, @@ -66,6 +73,9 @@ describe("createLocalToolsMcpServer", () => { expect(names).toContain("git_signed_commit"); expect(names).toContain("git_signed_merge"); expect(names).toContain("git_signed_rewrite"); + // The adapter resolves spokenNarration before building the server; without + // an explicit true here the speak tool stays gated off. + expect(names).not.toContain("speak"); await client.close(); }); diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts index ad6102f4d6..60a200126c 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts @@ -108,6 +108,30 @@ describe("canUseTool MCP approval enforcement", () => { expect(context.client.requestPermission).toHaveBeenCalled(); }); + it("auto-allows the speak narration tool without prompting", async () => { + const context = createContext("mcp__posthog-code-tools__speak", { + toolInput: { text: "all tests pass", kind: "done" }, + }); + const result = await canUseTool(context); + + expect(result.behavior).toBe("allow"); + expect(context.client.requestPermission).not.toHaveBeenCalled(); + }); + + it("blocks speak when its approval state is do_not_use", async () => { + setMcpToolApprovalStates({ + "mcp__posthog-code-tools__speak": "do_not_use", + }); + + const context = createContext("mcp__posthog-code-tools__speak", { + toolInput: { text: "all tests pass", kind: "done" }, + }); + const result = await canUseTool(context); + + expect(result.behavior).toBe("deny"); + expect(context.client.requestPermission).not.toHaveBeenCalled(); + }); + it("tags MCP tools in the default permission flow with claudeCode.toolName so the renderer can show the server name and unwrap exec dispatch args", async () => { setMcpToolApprovalStates({ mcp__posthog__exec: "approved" }); diff --git a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts index 8eddc69777..979f62140e 100644 --- a/packages/agent/src/adapters/claude/permissions/permission-handlers.ts +++ b/packages/agent/src/adapters/claude/permissions/permission-handlers.ts @@ -9,6 +9,8 @@ import type { } from "@anthropic-ai/claude-agent-sdk"; import { text } from "../../../utils/acp-content"; import type { Logger } from "../../../utils/logger"; +import { qualifiedLocalToolName } from "../../local-tools"; +import { SPEAK_TOOL_NAME } from "../../local-tools/tools/speak"; import { toolInfoFromToolUse } from "../conversion/tool-use-to-acp"; import { getMcpToolApprovalState, @@ -38,6 +40,8 @@ import { isPostHogExecTool, } from "./posthog-exec-gate"; +const SPEAK_TOOL_ID = qualifiedLocalToolName(SPEAK_TOOL_NAME); + export type ToolPermissionResult = | { behavior: "allow"; @@ -757,6 +761,16 @@ export async function canUseTool( return { behavior: "deny", message, interrupt: false }; } + // Narration is a fire-and-forget no-op on the agent side; a permission + // prompt for it interrupts the user to approve a line they may never hear. + // An explicit do_not_use block above still wins. + if (toolName === SPEAK_TOOL_ID) { + return { + behavior: "allow", + updatedInput: toolInput as Record, + }; + } + if (approvalState === "needs_approval") { return handleMcpApprovalFlow(context); } diff --git a/packages/agent/src/adapters/claude/session/instructions.test.ts b/packages/agent/src/adapters/claude/session/instructions.test.ts new file mode 100644 index 0000000000..0721a80f67 --- /dev/null +++ b/packages/agent/src/adapters/claude/session/instructions.test.ts @@ -0,0 +1,23 @@ +import { describe, expect, it } from "vitest"; +import { buildAppendedInstructions } from "./instructions"; + +describe("buildAppendedInstructions", () => { + it("includes the spoken-narration block when narration is on", () => { + const instructions = buildAppendedInstructions({ spokenNarration: true }); + expect(instructions).toContain("# Spoken Narration"); + }); + + it("omits the spoken-narration block when narration is off", () => { + const instructions = buildAppendedInstructions({ spokenNarration: false }); + expect(instructions).not.toContain("Spoken Narration"); + }); + + it("keeps the base blocks in both modes", () => { + const withNarration = buildAppendedInstructions({ spokenNarration: true }); + const withoutNarration = buildAppendedInstructions({ + spokenNarration: false, + }); + expect(withNarration.startsWith(withoutNarration)).toBe(true); + expect(withoutNarration.length).toBeGreaterThan(0); + }); +}); diff --git a/packages/agent/src/adapters/claude/session/instructions.ts b/packages/agent/src/adapters/claude/session/instructions.ts index 3f79a9c7c4..39d78a80b7 100644 --- a/packages/agent/src/adapters/claude/session/instructions.ts +++ b/packages/agent/src/adapters/claude/session/instructions.ts @@ -63,10 +63,13 @@ How to phrase the line: - Be theatrical: use expressive audio tags in [square brackets] — [laughs], [sighs], [groans], [excited], [whispers], [clears throat] — 1-3 per line, matched to the moment. The system-voice fallback strips tags automatically, so they never hurt. `; -export const APPENDED_INSTRUCTIONS = - BRANCH_NAMING + - PULL_REQUEST_LINKS + - PLAN_MODE + - MCP_TOOLS + - SHELL_EFFICIENCY + - SPOKEN_NARRATION; +const BASE_INSTRUCTIONS = + BRANCH_NAMING + PULL_REQUEST_LINKS + PLAN_MODE + MCP_TOOLS + SHELL_EFFICIENCY; + +export function buildAppendedInstructions(opts: { + spokenNarration: boolean; +}): string { + return opts.spokenNarration + ? BASE_INSTRUCTIONS + SPOKEN_NARRATION + : BASE_INSTRUCTIONS; +} diff --git a/packages/agent/src/adapters/claude/session/options.test.ts b/packages/agent/src/adapters/claude/session/options.test.ts index 6056e6cb03..9698920c27 100644 --- a/packages/agent/src/adapters/claude/session/options.test.ts +++ b/packages/agent/src/adapters/claude/session/options.test.ts @@ -5,7 +5,7 @@ import type { HookInput, Options } from "@anthropic-ai/claude-agent-sdk"; import { afterEach, beforeEach, describe, expect, it, vi } from "vitest"; import { Logger } from "../../../utils/logger"; import { SUBAGENT_REWRITES } from "../hooks"; -import { buildSessionOptions } from "./options"; +import { buildSessionOptions, buildSystemPrompt } from "./options"; import { SettingsManager } from "./settings"; const GIT_COMMIT_HOOK_INPUT = { @@ -407,3 +407,66 @@ describe("buildSessionOptions", () => { }); }); }); + +describe("buildSystemPrompt", () => { + const promptText = (prompt: Options["systemPrompt"]): string => { + if (typeof prompt === "string") return prompt; + if (Array.isArray(prompt)) return prompt.join("\n"); + return prompt?.append ?? ""; + }; + + const prompts = [ + { name: "default preset", customPrompt: undefined }, + { name: "string prompt", customPrompt: "You are a test agent." }, + { + name: "preset with append", + customPrompt: { + type: "preset", + preset: "claude_code", + append: "Custom append.", + }, + }, + ]; + + it.each(prompts)( + "appends the narration block with narration on ($name)", + ({ customPrompt }) => { + const prompt = buildSystemPrompt(customPrompt, { spokenNarration: true }); + expect(promptText(prompt)).toContain("# Spoken Narration"); + }, + ); + + it.each(prompts)( + "omits the narration block with narration off ($name)", + ({ customPrompt }) => { + const prompt = buildSystemPrompt(customPrompt, { + spokenNarration: false, + }); + expect(promptText(prompt)).not.toContain("Spoken Narration"); + }, + ); + + it.each(prompts)( + "omits the narration block when opts are absent ($name)", + ({ customPrompt }) => { + const prompt = buildSystemPrompt(customPrompt); + expect(promptText(prompt)).not.toContain("Spoken Narration"); + }, + ); + + it("keeps the custom prompt ahead of the appended instructions", () => { + const prompt = buildSystemPrompt("You are a test agent.", { + spokenNarration: true, + }); + expect(typeof prompt).toBe("string"); + expect(prompt).toMatch(/^You are a test agent\./); + }); + + it("keeps the custom append ahead of the appended instructions", () => { + const prompt = buildSystemPrompt( + { type: "preset", preset: "claude_code", append: "Custom append." }, + { spokenNarration: true }, + ); + expect(promptText(prompt)).toMatch(/^Custom append\./); + }); +}); diff --git a/packages/agent/src/adapters/claude/session/options.ts b/packages/agent/src/adapters/claude/session/options.ts index a908b874bc..220d96b90f 100644 --- a/packages/agent/src/adapters/claude/session/options.ts +++ b/packages/agent/src/adapters/claude/session/options.ts @@ -28,7 +28,7 @@ import { } from "../hooks"; import { type CodeExecutionMode, toSdkPermissionMode } from "../tools"; import type { EffortLevel } from "../types"; -import { APPENDED_INSTRUCTIONS } from "./instructions"; +import { buildAppendedInstructions } from "./instructions"; import { loadUserClaudeJsonMcpServers } from "./mcp-config"; import { DEFAULT_MODEL, FALLBACK_MODEL } from "./models"; import { createRtkRewriteHook, resolveRtkPrefix } from "./rtk"; @@ -102,11 +102,15 @@ export interface BuildOptionsParams { export function buildSystemPrompt( customPrompt?: unknown, + opts?: { spokenNarration?: boolean }, ): Options["systemPrompt"] { + const appendedInstructions = buildAppendedInstructions({ + spokenNarration: opts?.spokenNarration === true, + }); const defaultPrompt: Options["systemPrompt"] = { type: "preset", preset: "claude_code", - append: APPENDED_INSTRUCTIONS, + append: appendedInstructions, }; if (!customPrompt) { @@ -114,7 +118,7 @@ export function buildSystemPrompt( } if (typeof customPrompt === "string") { - return customPrompt + APPENDED_INSTRUCTIONS; + return customPrompt + appendedInstructions; } if ( @@ -125,7 +129,7 @@ export function buildSystemPrompt( ) { return { ...defaultPrompt, - append: customPrompt.append + APPENDED_INSTRUCTIONS, + append: customPrompt.append + appendedInstructions, }; } diff --git a/packages/agent/src/adapters/claude/types.ts b/packages/agent/src/adapters/claude/types.ts index 3d423746d1..80078dfa8f 100644 --- a/packages/agent/src/adapters/claude/types.ts +++ b/packages/agent/src/adapters/claude/types.ts @@ -194,6 +194,12 @@ export type NewSessionMeta = { * runtime whether it needs a repo and clones one only if so. */ channelMode?: boolean; + /** + * The user's spoken-narration setting at session start. Gates the speak + * tool and its prompt instructions. Unset falls back by environment: cloud + * emits always (consumers gate playback), local stays silent. + */ + spokenNarration?: boolean; jsonSchema?: Record | null; mcpToolApprovals?: McpToolApprovals; claudeCode?: { diff --git a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts index b39035fc32..1271ea62b5 100644 --- a/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts +++ b/packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts @@ -36,6 +36,7 @@ import { estimateTokens, } from "../claude/context-breakdown"; import { isLocalSkillCommandChunk } from "../local-skill"; +import { resolveSpokenNarration } from "../session-meta"; import { AppServerClient, type AppServerClientHandlers, @@ -84,6 +85,7 @@ type AppServerSessionMeta = { persistence?: { taskId?: string }; environment?: "local" | "cloud"; channelMode?: boolean; + spokenNarration?: boolean; baseBranch?: string; }; @@ -437,6 +439,7 @@ export class CodexAppServerAgent extends BaseAcpAgent { return { environment: meta.environment, channelMode: meta.channelMode, + spokenNarration: resolveSpokenNarration(meta), taskId: meta.taskId, taskRunId: meta.taskRunId, persistence: meta.persistence, diff --git a/packages/agent/src/adapters/codex-app-server/local-tools-mcp.test.ts b/packages/agent/src/adapters/codex-app-server/local-tools-mcp.test.ts index e3b0efd636..0b53b4ceac 100644 --- a/packages/agent/src/adapters/codex-app-server/local-tools-mcp.test.ts +++ b/packages/agent/src/adapters/codex-app-server/local-tools-mcp.test.ts @@ -105,12 +105,12 @@ describe("buildLocalToolsServer", () => { ).toBeNull(); }); - it("exposes only the always-on tools on a desktop run (no cloud-only tools)", () => { + it("exposes speak on a desktop run with narration on (no cloud-only tools)", () => { process.env.GH_TOKEN = "ghs_test"; const server = buildLocalToolsServer( { cwd: "/repo" }, - { environment: "local" }, + { environment: "local", spokenNarration: true }, ); expect(server).not.toBeNull(); @@ -118,9 +118,16 @@ describe("buildLocalToolsServer", () => { server?.env.find((e) => e.name === "POSTHOG_LOCAL_TOOLS_ENABLED") ?.value ?? ""; const names = enabled.split(","); - // `speak` is always on (narration works on desktop and cloud alike). expect(names).toContain("speak"); // Signed-git tools are cloud-only and must not leak into a desktop run. expect(names).not.toContain("git_signed_commit"); }); + + it("returns null on a desktop run with narration off (no tools pass their gate)", () => { + process.env.GH_TOKEN = "ghs_test"; + + expect( + buildLocalToolsServer({ cwd: "/repo" }, { environment: "local" }), + ).toBeNull(); + }); }); diff --git a/packages/agent/src/adapters/local-tools/registry.ts b/packages/agent/src/adapters/local-tools/registry.ts index 01e38450dd..d40c327f3b 100644 --- a/packages/agent/src/adapters/local-tools/registry.ts +++ b/packages/agent/src/adapters/local-tools/registry.ts @@ -28,6 +28,8 @@ export interface LocalToolGateMeta { environment?: "local" | "cloud"; /** Repo-less channel session: enables the lazy-repo tools. */ channelMode?: boolean; + /** Spoken narration is on for this session: enables the speak tool. */ + spokenNarration?: boolean; } /** diff --git a/packages/agent/src/adapters/local-tools/tools/speak.test.ts b/packages/agent/src/adapters/local-tools/tools/speak.test.ts index 32ee140745..9eba64a0f6 100644 --- a/packages/agent/src/adapters/local-tools/tools/speak.test.ts +++ b/packages/agent/src/adapters/local-tools/tools/speak.test.ts @@ -6,14 +6,24 @@ describe("speak tool", () => { it.each([ { name: "desktop", environment: "local" as const }, { name: "cloud", environment: "cloud" as const }, - ])("is always exposed ($name)", ({ environment }) => { - const tools = enabledLocalTools({ cwd: "/repo" }, { environment }); + ])("is exposed with narration on ($name)", ({ environment }) => { + const tools = enabledLocalTools( + { cwd: "/repo" }, + { environment, spokenNarration: true }, + ); expect(tools.some((t) => t.name === SPEAK_TOOL_NAME)).toBe(true); }); - it("is exposed even with no gate meta", () => { - const tools = enabledLocalTools({ cwd: "/repo" }, undefined); - expect(tools.some((t) => t.name === SPEAK_TOOL_NAME)).toBe(true); + it.each([ + { + name: "narration off", + meta: { environment: "local" as const, spokenNarration: false }, + }, + { name: "narration unset", meta: { environment: "local" as const } }, + { name: "no gate meta", meta: undefined }, + ])("is hidden with $name", ({ meta }) => { + const tools = enabledLocalTools({ cwd: "/repo" }, meta); + expect(tools.some((t) => t.name === SPEAK_TOOL_NAME)).toBe(false); }); it("stays visible without ToolSearch (alwaysLoad)", () => { diff --git a/packages/agent/src/adapters/local-tools/tools/speak.ts b/packages/agent/src/adapters/local-tools/tools/speak.ts index 5559dc1609..83ef123eaf 100644 --- a/packages/agent/src/adapters/local-tools/tools/speak.ts +++ b/packages/agent/src/adapters/local-tools/tools/speak.ts @@ -48,15 +48,17 @@ 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. Always enabled — gating happens on - * the consumer side via the user's "spoken narration" setting. + * 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). */ export const speakTool = defineLocalTool({ name: SPEAK_TOOL_NAME, description: SPEAK_TOOL_DESCRIPTION, schema: speakSchema, alwaysLoad: true, - isEnabled: () => true, + isEnabled: (_ctx, meta) => meta?.spokenNarration === true, handler: async (): Promise => { return { content: [{ type: "text", text: "ok" }] }; }, diff --git a/packages/agent/src/adapters/session-meta.test.ts b/packages/agent/src/adapters/session-meta.test.ts new file mode 100644 index 0000000000..e65730aa00 --- /dev/null +++ b/packages/agent/src/adapters/session-meta.test.ts @@ -0,0 +1,57 @@ +import { afterEach, describe, expect, it, vi } from "vitest"; +import { resolveSpokenNarration } from "./session-meta"; + +describe("resolveSpokenNarration", () => { + afterEach(() => { + vi.unstubAllEnvs(); + }); + + it.each([ + { + name: "explicit true on local", + meta: { environment: "local" as const, 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 }, + expected: false, + }, + { name: "no meta", meta: undefined, expected: false }, + { name: "empty meta", meta: {}, expected: false }, + ])("resolves $name to $expected outside a sandbox", ({ meta, expected }) => { + vi.stubEnv("IS_SANDBOX", ""); + expect(resolveSpokenNarration(meta)).toBe(expected); + }); + + it.each([ + { name: "no meta", meta: undefined, expected: true }, + { name: "empty meta", meta: {}, expected: true }, + { + name: "explicit false", + meta: { spokenNarration: false }, + expected: false, + }, + { + name: "explicit local environment", + meta: { environment: "local" as const }, + expected: false, + }, + ])( + "resolves $name to $expected in a sandbox without an environment tag", + ({ 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 931c45f218..f70de89744 100644 --- a/packages/agent/src/adapters/session-meta.ts +++ b/packages/agent/src/adapters/session-meta.ts @@ -1,3 +1,5 @@ +import { isCloudRun } from "../utils/common"; + /** Minimal shape needed to resolve the effective task id from session meta. */ interface TaskIdSource { taskId?: string; @@ -14,3 +16,21 @@ export function resolveTaskId( ): string | undefined { return meta?.taskId ?? meta?.persistence?.taskId; } + +/** 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. + */ +export function resolveSpokenNarration( + meta: SpokenNarrationSource | undefined, +): boolean { + return meta?.spokenNarration ?? isCloudRun(meta); +} diff --git a/packages/core/src/sessions/sessionService.ts b/packages/core/src/sessions/sessionService.ts index c883aed101..b70709057b 100644 --- a/packages/core/src/sessions/sessionService.ts +++ b/packages/core/src/sessions/sessionService.ts @@ -320,6 +320,7 @@ export interface SessionServiceDeps { customInstructions?: string | null; rtkEnabledLocal?: boolean; rtkEnabledCloud?: boolean; + spokenNotifications?: boolean; }; usageLimit: { show: (...args: any[]) => any }; readonly addDirectoryDialog: { open: boolean }; @@ -1068,12 +1069,14 @@ export class SessionService { this.d.log.warn("Failed to verify workspace", { taskId, err }); }); - const { customInstructions, rtkEnabledLocal } = this.d.settings; + const { customInstructions, rtkEnabledLocal, spokenNotifications } = + this.d.settings; const result = await this.d.trpc.agent.reconnect.mutate({ taskId, taskRunId, repoPath, rtkEnabled: rtkEnabledLocal, + spokenNarration: spokenNotifications === true, apiHost: auth.apiHost, projectId: auth.projectId, logUrl, @@ -1393,7 +1396,11 @@ export class SessionService { throw new Error("Failed to create task run. Please try again."); } - const { customInstructions: startCustomInstructions } = this.d.settings; + const { + customInstructions: startCustomInstructions, + rtkEnabledLocal, + spokenNotifications, + } = this.d.settings; const preferredModel = model ?? this.d.DEFAULT_GATEWAY_MODEL; const result = await this.d.trpc.agent.start.mutate({ taskId, @@ -1404,7 +1411,8 @@ export class SessionService { permissionMode: executionMode, adapter, customInstructions: startCustomInstructions || undefined, - rtkEnabled: this.d.settings.rtkEnabledLocal, + rtkEnabled: rtkEnabledLocal, + spokenNarration: spokenNotifications === true, effort: effortLevelSchema.safeParse(reasoningLevel).success ? (reasoningLevel as EffortLevel) : undefined, diff --git a/packages/workspace-server/src/services/agent/agent.test.ts b/packages/workspace-server/src/services/agent/agent.test.ts index 88068a71fe..37374a1bc6 100644 --- a/packages/workspace-server/src/services/agent/agent.test.ts +++ b/packages/workspace-server/src/services/agent/agent.test.ts @@ -347,6 +347,36 @@ describe("AgentService", () => { }); }); + describe("session meta", () => { + it.each([{ spokenNarration: true }, { spokenNarration: false }])( + "threads spokenNarration $spokenNarration into newSession meta", + async ({ spokenNarration }) => { + await service.startSession({ + ...baseSessionParams, + adapter: "claude", + spokenNarration, + }); + + expect(mockNewSession).toHaveBeenCalledTimes(1); + expect(mockNewSession.mock.calls[0][0]._meta).toMatchObject({ + spokenNarration, + }); + }, + ); + + it("omits spokenNarration from newSession meta when unset", async () => { + await service.startSession({ + ...baseSessionParams, + adapter: "claude", + }); + + expect(mockNewSession).toHaveBeenCalledTimes(1); + expect(mockNewSession.mock.calls[0][0]._meta).not.toHaveProperty( + "spokenNarration", + ); + }); + }); + describe("idle timeout", () => { function injectSession( svc: AgentService, diff --git a/packages/workspace-server/src/services/agent/agent.ts b/packages/workspace-server/src/services/agent/agent.ts index e0852eae1d..f79648cf89 100644 --- a/packages/workspace-server/src/services/agent/agent.ts +++ b/packages/workspace-server/src/services/agent/agent.ts @@ -284,6 +284,8 @@ interface SessionConfig { importedSessionId?: string; /** rtk command-output compression for this session; false opts out. */ rtkEnabled?: boolean; + /** The user's spoken-narration setting at session start. */ + spokenNarration?: boolean; } /** Pull the adapter's `agentCapabilities._meta.posthog.steering` from initialize. */ @@ -971,6 +973,9 @@ If a repository IS genuinely required, attach one in this priority order: sessionId: importedSessionId, systemPrompt, ...(channelMode && { channelMode }), + ...(config.spokenNarration !== undefined && { + spokenNarration: config.spokenNarration, + }), mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), @@ -1043,6 +1048,9 @@ If a repository IS genuinely required, attach one in this priority order: sessionId: existingSessionId, systemPrompt, ...(channelMode && { channelMode }), + ...(config.spokenNarration !== undefined && { + spokenNarration: config.spokenNarration, + }), mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), @@ -1069,6 +1077,9 @@ If a repository IS genuinely required, attach one in this priority order: environment: "local", systemPrompt, ...(channelMode && { channelMode }), + ...(config.spokenNarration !== undefined && { + spokenNarration: config.spokenNarration, + }), mcpToolApprovals: toolApprovals, ...(permissionMode && { permissionMode }), ...(model != null && { model }), @@ -1978,6 +1989,8 @@ For git operations while detached: importedSessionId: "importedSessionId" in params ? params.importedSessionId : undefined, rtkEnabled: "rtkEnabled" in params ? params.rtkEnabled : undefined, + spokenNarration: + "spokenNarration" in params ? params.spokenNarration : undefined, }; } diff --git a/packages/workspace-server/src/services/agent/schemas.ts b/packages/workspace-server/src/services/agent/schemas.ts index 78bf6a0d8c..9234cd7f6e 100644 --- a/packages/workspace-server/src/services/agent/schemas.ts +++ b/packages/workspace-server/src/services/agent/schemas.ts @@ -91,6 +91,12 @@ export const startSessionInput = z.object({ * Defaults to enabled; false sets POSTHOG_RTK=0 on the agent environment. */ 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). + */ + spokenNarration: z.boolean().optional(), }); export type StartSessionInput = z.infer; @@ -224,6 +230,8 @@ export const reconnectSessionInput = z.object({ jsonSchema: z.record(z.string(), z.unknown()).nullish(), /** See startSessionInput.rtkEnabled. */ rtkEnabled: z.boolean().optional(), + /** See startSessionInput.spokenNarration. */ + spokenNarration: z.boolean().optional(), }); export type ReconnectSessionInput = z.infer;