Skip to content

Commit 5e35d6e

Browse files
authored
fix(agent): Gate spoken narration on the user setting (#3461)
1 parent 83a4cc7 commit 5e35d6e

20 files changed

Lines changed: 342 additions & 32 deletions

File tree

packages/agent/src/adapters/claude/claude-agent.ts

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -75,7 +75,7 @@ import { Logger } from "../../utils/logger";
7575
import { Pushable } from "../../utils/streams";
7676
import { BaseAcpAgent } from "../base-acp-agent";
7777
import { LOCAL_TOOLS_MCP_NAME } from "../local-tools";
78-
import { resolveTaskId } from "../session-meta";
78+
import { resolveSpokenNarration, resolveTaskId } from "../session-meta";
7979
import {
8080
buildBreakdown,
8181
emptyBaseline,
@@ -1891,6 +1891,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
18911891
// needs so the session doesn't pin the whole meta object.
18921892
const baseBranch = meta?.baseBranch;
18931893
const environment = meta?.environment;
1894+
const spokenNarration = resolveSpokenNarration(meta);
18941895
const buildInProcessMcpServers = (): Record<
18951896
string,
18961897
McpSdkServerConfigWithInstance
@@ -1903,7 +1904,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
19031904
taskRunId: meta?.taskRunId,
19041905
baseBranch,
19051906
},
1906-
{ environment },
1907+
{ environment, spokenNarration },
19071908
);
19081909
return server ? { [LOCAL_TOOLS_MCP_NAME]: server } : {};
19091910
};
@@ -1923,7 +1924,9 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
19231924
...initialInProcess,
19241925
};
19251926

1926-
const systemPrompt = buildSystemPrompt(meta?.systemPrompt);
1927+
const systemPrompt = buildSystemPrompt(meta?.systemPrompt, {
1928+
spokenNarration,
1929+
});
19271930

19281931
if (meta?.mcpToolApprovals) {
19291932
setMcpToolApprovalStates(meta.mcpToolApprovals);

packages/agent/src/adapters/claude/mcp/local-tools.test.ts

Lines changed: 13 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -20,10 +20,10 @@ describe("createLocalToolsMcpServer", () => {
2020
}
2121
});
2222

23-
it("exposes only the always-on tools on a desktop run (no cloud-only tools)", async () => {
23+
it("exposes speak on a desktop run with narration on (no cloud-only tools)", async () => {
2424
const server = createLocalToolsMcpServer(
2525
{ cwd: "/repo", token: "ghs_x" },
26-
undefined,
26+
{ environment: "local", spokenNarration: true },
2727
);
2828
if (!server) {
2929
throw new Error("expected the local-tools server to be registered");
@@ -37,14 +37,21 @@ describe("createLocalToolsMcpServer", () => {
3737

3838
const { tools } = await client.listTools();
3939
const names = tools.map((t) => t.name);
40-
// `speak` is always on (narration works on desktop and cloud alike).
4140
expect(names).toContain("speak");
4241
// Signed-git tools are cloud-only and must not leak into a desktop run.
4342
expect(names).not.toContain("git_signed_commit");
4443

4544
await client.close();
4645
});
4746

47+
it("registers no server on a desktop run with narration off (no tools pass their gate)", () => {
48+
const server = createLocalToolsMcpServer(
49+
{ cwd: "/repo", token: "ghs_x" },
50+
undefined,
51+
);
52+
expect(server).toBeUndefined();
53+
});
54+
4855
it("exposes git_signed_commit over MCP in a cloud run with a token", async () => {
4956
const server = createLocalToolsMcpServer(
5057
{ cwd: "/repo", token: "ghs_x" },
@@ -66,6 +73,9 @@ describe("createLocalToolsMcpServer", () => {
6673
expect(names).toContain("git_signed_commit");
6774
expect(names).toContain("git_signed_merge");
6875
expect(names).toContain("git_signed_rewrite");
76+
// The adapter resolves spokenNarration before building the server; without
77+
// an explicit true here the speak tool stays gated off.
78+
expect(names).not.toContain("speak");
6979

7080
await client.close();
7181
});

packages/agent/src/adapters/claude/permissions/permission-handlers.test.ts

Lines changed: 24 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -108,6 +108,30 @@ describe("canUseTool MCP approval enforcement", () => {
108108
expect(context.client.requestPermission).toHaveBeenCalled();
109109
});
110110

111+
it("auto-allows the speak narration tool without prompting", async () => {
112+
const context = createContext("mcp__posthog-code-tools__speak", {
113+
toolInput: { text: "all tests pass", kind: "done" },
114+
});
115+
const result = await canUseTool(context);
116+
117+
expect(result.behavior).toBe("allow");
118+
expect(context.client.requestPermission).not.toHaveBeenCalled();
119+
});
120+
121+
it("blocks speak when its approval state is do_not_use", async () => {
122+
setMcpToolApprovalStates({
123+
"mcp__posthog-code-tools__speak": "do_not_use",
124+
});
125+
126+
const context = createContext("mcp__posthog-code-tools__speak", {
127+
toolInput: { text: "all tests pass", kind: "done" },
128+
});
129+
const result = await canUseTool(context);
130+
131+
expect(result.behavior).toBe("deny");
132+
expect(context.client.requestPermission).not.toHaveBeenCalled();
133+
});
134+
111135
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 () => {
112136
setMcpToolApprovalStates({ mcp__posthog__exec: "approved" });
113137

packages/agent/src/adapters/claude/permissions/permission-handlers.ts

Lines changed: 14 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -9,6 +9,8 @@ import type {
99
} from "@anthropic-ai/claude-agent-sdk";
1010
import { text } from "../../../utils/acp-content";
1111
import type { Logger } from "../../../utils/logger";
12+
import { qualifiedLocalToolName } from "../../local-tools";
13+
import { SPEAK_TOOL_NAME } from "../../local-tools/tools/speak";
1214
import { toolInfoFromToolUse } from "../conversion/tool-use-to-acp";
1315
import {
1416
getMcpToolApprovalState,
@@ -38,6 +40,8 @@ import {
3840
isPostHogExecTool,
3941
} from "./posthog-exec-gate";
4042

43+
const SPEAK_TOOL_ID = qualifiedLocalToolName(SPEAK_TOOL_NAME);
44+
4145
export type ToolPermissionResult =
4246
| {
4347
behavior: "allow";
@@ -757,6 +761,16 @@ export async function canUseTool(
757761
return { behavior: "deny", message, interrupt: false };
758762
}
759763

764+
// Narration is a fire-and-forget no-op on the agent side; a permission
765+
// prompt for it interrupts the user to approve a line they may never hear.
766+
// An explicit do_not_use block above still wins.
767+
if (toolName === SPEAK_TOOL_ID) {
768+
return {
769+
behavior: "allow",
770+
updatedInput: toolInput as Record<string, unknown>,
771+
};
772+
}
773+
760774
if (approvalState === "needs_approval") {
761775
return handleMcpApprovalFlow(context);
762776
}
Lines changed: 23 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,23 @@
1+
import { describe, expect, it } from "vitest";
2+
import { buildAppendedInstructions } from "./instructions";
3+
4+
describe("buildAppendedInstructions", () => {
5+
it("includes the spoken-narration block when narration is on", () => {
6+
const instructions = buildAppendedInstructions({ spokenNarration: true });
7+
expect(instructions).toContain("# Spoken Narration");
8+
});
9+
10+
it("omits the spoken-narration block when narration is off", () => {
11+
const instructions = buildAppendedInstructions({ spokenNarration: false });
12+
expect(instructions).not.toContain("Spoken Narration");
13+
});
14+
15+
it("keeps the base blocks in both modes", () => {
16+
const withNarration = buildAppendedInstructions({ spokenNarration: true });
17+
const withoutNarration = buildAppendedInstructions({
18+
spokenNarration: false,
19+
});
20+
expect(withNarration.startsWith(withoutNarration)).toBe(true);
21+
expect(withoutNarration.length).toBeGreaterThan(0);
22+
});
23+
});

packages/agent/src/adapters/claude/session/instructions.ts

Lines changed: 10 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -63,10 +63,13 @@ How to phrase the line:
6363
- 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.
6464
`;
6565

66-
export const APPENDED_INSTRUCTIONS =
67-
BRANCH_NAMING +
68-
PULL_REQUEST_LINKS +
69-
PLAN_MODE +
70-
MCP_TOOLS +
71-
SHELL_EFFICIENCY +
72-
SPOKEN_NARRATION;
66+
const BASE_INSTRUCTIONS =
67+
BRANCH_NAMING + PULL_REQUEST_LINKS + PLAN_MODE + MCP_TOOLS + SHELL_EFFICIENCY;
68+
69+
export function buildAppendedInstructions(opts: {
70+
spokenNarration: boolean;
71+
}): string {
72+
return opts.spokenNarration
73+
? BASE_INSTRUCTIONS + SPOKEN_NARRATION
74+
: BASE_INSTRUCTIONS;
75+
}

packages/agent/src/adapters/claude/session/options.test.ts

Lines changed: 64 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,7 +5,7 @@ import type { HookInput, Options } from "@anthropic-ai/claude-agent-sdk";
55
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
66
import { Logger } from "../../../utils/logger";
77
import { SUBAGENT_REWRITES } from "../hooks";
8-
import { buildSessionOptions } from "./options";
8+
import { buildSessionOptions, buildSystemPrompt } from "./options";
99
import { SettingsManager } from "./settings";
1010

1111
const GIT_COMMIT_HOOK_INPUT = {
@@ -407,3 +407,66 @@ describe("buildSessionOptions", () => {
407407
});
408408
});
409409
});
410+
411+
describe("buildSystemPrompt", () => {
412+
const promptText = (prompt: Options["systemPrompt"]): string => {
413+
if (typeof prompt === "string") return prompt;
414+
if (Array.isArray(prompt)) return prompt.join("\n");
415+
return prompt?.append ?? "";
416+
};
417+
418+
const prompts = [
419+
{ name: "default preset", customPrompt: undefined },
420+
{ name: "string prompt", customPrompt: "You are a test agent." },
421+
{
422+
name: "preset with append",
423+
customPrompt: {
424+
type: "preset",
425+
preset: "claude_code",
426+
append: "Custom append.",
427+
},
428+
},
429+
];
430+
431+
it.each(prompts)(
432+
"appends the narration block with narration on ($name)",
433+
({ customPrompt }) => {
434+
const prompt = buildSystemPrompt(customPrompt, { spokenNarration: true });
435+
expect(promptText(prompt)).toContain("# Spoken Narration");
436+
},
437+
);
438+
439+
it.each(prompts)(
440+
"omits the narration block with narration off ($name)",
441+
({ customPrompt }) => {
442+
const prompt = buildSystemPrompt(customPrompt, {
443+
spokenNarration: false,
444+
});
445+
expect(promptText(prompt)).not.toContain("Spoken Narration");
446+
},
447+
);
448+
449+
it.each(prompts)(
450+
"omits the narration block when opts are absent ($name)",
451+
({ customPrompt }) => {
452+
const prompt = buildSystemPrompt(customPrompt);
453+
expect(promptText(prompt)).not.toContain("Spoken Narration");
454+
},
455+
);
456+
457+
it("keeps the custom prompt ahead of the appended instructions", () => {
458+
const prompt = buildSystemPrompt("You are a test agent.", {
459+
spokenNarration: true,
460+
});
461+
expect(typeof prompt).toBe("string");
462+
expect(prompt).toMatch(/^You are a test agent\./);
463+
});
464+
465+
it("keeps the custom append ahead of the appended instructions", () => {
466+
const prompt = buildSystemPrompt(
467+
{ type: "preset", preset: "claude_code", append: "Custom append." },
468+
{ spokenNarration: true },
469+
);
470+
expect(promptText(prompt)).toMatch(/^Custom append\./);
471+
});
472+
});

packages/agent/src/adapters/claude/session/options.ts

Lines changed: 8 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -28,7 +28,7 @@ import {
2828
} from "../hooks";
2929
import { type CodeExecutionMode, toSdkPermissionMode } from "../tools";
3030
import type { EffortLevel } from "../types";
31-
import { APPENDED_INSTRUCTIONS } from "./instructions";
31+
import { buildAppendedInstructions } from "./instructions";
3232
import { loadUserClaudeJsonMcpServers } from "./mcp-config";
3333
import { DEFAULT_MODEL, FALLBACK_MODEL } from "./models";
3434
import { createRtkRewriteHook, resolveRtkPrefix } from "./rtk";
@@ -102,19 +102,23 @@ export interface BuildOptionsParams {
102102

103103
export function buildSystemPrompt(
104104
customPrompt?: unknown,
105+
opts?: { spokenNarration?: boolean },
105106
): Options["systemPrompt"] {
107+
const appendedInstructions = buildAppendedInstructions({
108+
spokenNarration: opts?.spokenNarration === true,
109+
});
106110
const defaultPrompt: Options["systemPrompt"] = {
107111
type: "preset",
108112
preset: "claude_code",
109-
append: APPENDED_INSTRUCTIONS,
113+
append: appendedInstructions,
110114
};
111115

112116
if (!customPrompt) {
113117
return defaultPrompt;
114118
}
115119

116120
if (typeof customPrompt === "string") {
117-
return customPrompt + APPENDED_INSTRUCTIONS;
121+
return customPrompt + appendedInstructions;
118122
}
119123

120124
if (
@@ -125,7 +129,7 @@ export function buildSystemPrompt(
125129
) {
126130
return {
127131
...defaultPrompt,
128-
append: customPrompt.append + APPENDED_INSTRUCTIONS,
132+
append: customPrompt.append + appendedInstructions,
129133
};
130134
}
131135

packages/agent/src/adapters/claude/types.ts

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -194,6 +194,12 @@ export type NewSessionMeta = {
194194
* runtime whether it needs a repo and clones one only if so.
195195
*/
196196
channelMode?: boolean;
197+
/**
198+
* The user's spoken-narration setting at session start. Gates the speak
199+
* tool and its prompt instructions. Unset falls back by environment: cloud
200+
* emits always (consumers gate playback), local stays silent.
201+
*/
202+
spokenNarration?: boolean;
197203
jsonSchema?: Record<string, unknown> | null;
198204
mcpToolApprovals?: McpToolApprovals;
199205
claudeCode?: {

packages/agent/src/adapters/codex-app-server/codex-app-server-agent.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ import {
3636
estimateTokens,
3737
} from "../claude/context-breakdown";
3838
import { isLocalSkillCommandChunk } from "../local-skill";
39+
import { resolveSpokenNarration } from "../session-meta";
3940
import {
4041
AppServerClient,
4142
type AppServerClientHandlers,
@@ -84,6 +85,7 @@ type AppServerSessionMeta = {
8485
persistence?: { taskId?: string };
8586
environment?: "local" | "cloud";
8687
channelMode?: boolean;
88+
spokenNarration?: boolean;
8789
baseBranch?: string;
8890
};
8991

@@ -437,6 +439,7 @@ export class CodexAppServerAgent extends BaseAcpAgent {
437439
return {
438440
environment: meta.environment,
439441
channelMode: meta.channelMode,
442+
spokenNarration: resolveSpokenNarration(meta),
440443
taskId: meta.taskId,
441444
taskRunId: meta.taskRunId,
442445
persistence: meta.persistence,

0 commit comments

Comments
 (0)