Skip to content
Merged
Show file tree
Hide file tree
Changes from all 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: 6 additions & 3 deletions packages/agent/src/adapters/claude/claude-agent.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -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
Expand All @@ -1903,7 +1904,7 @@ export class ClaudeAcpAgent extends BaseAcpAgent {
taskRunId: meta?.taskRunId,
baseBranch,
},
{ environment },
{ environment, spokenNarration },
);
return server ? { [LOCAL_TOOLS_MCP_NAME]: server } : {};
};
Expand All @@ -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);
Expand Down
16 changes: 13 additions & 3 deletions packages/agent/src/adapters/claude/mcp/local-tools.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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");
Expand All @@ -37,14 +37,21 @@ 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");

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" },
Expand All @@ -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();
});
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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" });

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down Expand Up @@ -38,6 +40,8 @@ import {
isPostHogExecTool,
} from "./posthog-exec-gate";

const SPEAK_TOOL_ID = qualifiedLocalToolName(SPEAK_TOOL_NAME);

export type ToolPermissionResult =
| {
behavior: "allow";
Expand Down Expand Up @@ -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<string, unknown>,
};
}

if (approvalState === "needs_approval") {
return handleMcpApprovalFlow(context);
}
Expand Down
23 changes: 23 additions & 0 deletions packages/agent/src/adapters/claude/session/instructions.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});
17 changes: 10 additions & 7 deletions packages/agent/src/adapters/claude/session/instructions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
65 changes: 64 additions & 1 deletion packages/agent/src/adapters/claude/session/options.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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 = {
Expand Down Expand Up @@ -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\./);
});
});
12 changes: 8 additions & 4 deletions packages/agent/src/adapters/claude/session/options.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand Down Expand Up @@ -102,19 +102,23 @@ 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) {
return defaultPrompt;
}

if (typeof customPrompt === "string") {
return customPrompt + APPENDED_INSTRUCTIONS;
return customPrompt + appendedInstructions;
}

if (
Expand All @@ -125,7 +129,7 @@ export function buildSystemPrompt(
) {
return {
...defaultPrompt,
append: customPrompt.append + APPENDED_INSTRUCTIONS,
append: customPrompt.append + appendedInstructions,
};
}

Expand Down
6 changes: 6 additions & 0 deletions packages/agent/src/adapters/claude/types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string, unknown> | null;
mcpToolApprovals?: McpToolApprovals;
claudeCode?: {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -36,6 +36,7 @@ import {
estimateTokens,
} from "../claude/context-breakdown";
import { isLocalSkillCommandChunk } from "../local-skill";
import { resolveSpokenNarration } from "../session-meta";
import {
AppServerClient,
type AppServerClientHandlers,
Expand Down Expand Up @@ -84,6 +85,7 @@ type AppServerSessionMeta = {
persistence?: { taskId?: string };
environment?: "local" | "cloud";
channelMode?: boolean;
spokenNarration?: boolean;
baseBranch?: string;
};

Expand Down Expand Up @@ -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,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -105,22 +105,29 @@ 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();
const enabled =
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();
});
});
2 changes: 2 additions & 0 deletions packages/agent/src/adapters/local-tools/registry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

/**
Expand Down
Loading
Loading