diff --git a/cli/src/claude/utils/claudeSettings.test.ts b/cli/src/claude/utils/claudeSettings.test.ts index 423edbb73..f9fbad5e4 100644 --- a/cli/src/claude/utils/claudeSettings.test.ts +++ b/cli/src/claude/utils/claudeSettings.test.ts @@ -1,14 +1,8 @@ -/** - * Tests for Claude settings reading functionality - * - * Tests reading Claude's settings.json file and respecting the includeCoAuthoredBy setting - */ - -import { describe, it, expect, beforeEach, afterEach } from 'vitest'; -import { existsSync, writeFileSync, unlinkSync, mkdirSync, rmSync } from 'node:fs'; +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, writeFileSync, mkdirSync, rmSync } from 'node:fs'; import { join } from 'node:path'; import { tmpdir } from 'node:os'; -import { readClaudeSettings, shouldIncludeCoAuthoredBy } from './claudeSettings'; +import { readClaudeSettings, shouldIncludeCoAuthoredBy, shouldEnableAutoTitle } from './claudeSettings'; describe('Claude Settings', () => { let testClaudeDir: string; @@ -92,4 +86,52 @@ describe('Claude Settings', () => { expect(result).toBe(true); }); }); +}); + +describe('shouldEnableAutoTitle', () => { + let testHapiDir: string; + let originalHapiHome: string | undefined; + + beforeEach(() => { + testHapiDir = join(tmpdir(), `test-hapi-${Date.now()}`); + mkdirSync(testHapiDir, { recursive: true }); + originalHapiHome = process.env.HAPI_HOME; + process.env.HAPI_HOME = testHapiDir; + + vi.resetModules(); + }); + + afterEach(() => { + if (originalHapiHome !== undefined) { + process.env.HAPI_HOME = originalHapiHome; + } else { + delete process.env.HAPI_HOME; + } + if (existsSync(testHapiDir)) { + rmSync(testHapiDir, { recursive: true, force: true }); + } + }); + + it('returns true when no settings file exists (default)', async () => { + const { shouldEnableAutoTitle } = await import('./claudeSettings'); + expect(shouldEnableAutoTitle()).toBe(true); + }); + + it('returns true when enableAutoTitle is not set', async () => { + writeFileSync(join(testHapiDir, 'settings.json'), JSON.stringify({ machineId: 'test' })); + const { shouldEnableAutoTitle } = await import('./claudeSettings'); + expect(shouldEnableAutoTitle()).toBe(true); + }); + + it('returns false when enableAutoTitle is explicitly false', async () => { + writeFileSync(join(testHapiDir, 'settings.json'), JSON.stringify({ enableAutoTitle: false })); + const { shouldEnableAutoTitle } = await import('./claudeSettings'); + expect(shouldEnableAutoTitle()).toBe(false); + }); + + it('returns true when enableAutoTitle is explicitly true', async () => { + writeFileSync(join(testHapiDir, 'settings.json'), JSON.stringify({ enableAutoTitle: true })); + const { shouldEnableAutoTitle } = await import('./claudeSettings'); + expect(shouldEnableAutoTitle()).toBe(true); + }); }); \ No newline at end of file diff --git a/cli/src/claude/utils/claudeSettings.ts b/cli/src/claude/utils/claudeSettings.ts index 356c17b59..75605b9b5 100644 --- a/cli/src/claude/utils/claudeSettings.ts +++ b/cli/src/claude/utils/claudeSettings.ts @@ -9,6 +9,7 @@ import { existsSync, readFileSync } from 'node:fs'; import { join } from 'node:path'; import { homedir } from 'node:os'; import { logger } from '@/ui/logger'; +import { readSettingsSync } from '@/persistence'; export interface ClaudeSettings { includeCoAuthoredBy?: boolean; @@ -58,12 +59,20 @@ export function readClaudeSettings(): ClaudeSettings | null { */ export function shouldIncludeCoAuthoredBy(): boolean { const settings = readClaudeSettings(); - + // If no settings file or includeCoAuthoredBy is not explicitly set, // default to true to maintain backward compatibility if (!settings || settings.includeCoAuthoredBy === undefined) { return true; } - + return settings.includeCoAuthoredBy; +} + +export function shouldEnableAutoTitle(): boolean { + const settings = readSettingsSync(); + if (settings.enableAutoTitle === undefined) { + return true; + } + return settings.enableAutoTitle; } \ No newline at end of file diff --git a/cli/src/claude/utils/startHappyServer.ts b/cli/src/claude/utils/startHappyServer.ts index 7383f54b3..7b9de6f82 100644 --- a/cli/src/claude/utils/startHappyServer.ts +++ b/cli/src/claude/utils/startHappyServer.ts @@ -11,6 +11,7 @@ import { z } from "zod"; import { logger } from "@/ui/logger"; import { ApiSessionClient } from "@/api/apiSession"; import { randomUUID } from "node:crypto"; +import { shouldEnableAutoTitle } from "./claudeSettings"; export async function startHappyServer(client: ApiSessionClient) { // Handler that sends title updates via the client @@ -39,41 +40,46 @@ export async function startHappyServer(client: ApiSessionClient) { version: "1.0.0", }); - // Avoid TS instantiation depth issues by widening the schema type. - const changeTitleInputSchema: z.ZodTypeAny = z.object({ - title: z.string().describe('The new title for the chat session'), - }); + const toolNames: string[] = []; - mcp.registerTool('change_title', { - description: 'Change the title of the current chat session', - title: 'Change Chat Title', - inputSchema: changeTitleInputSchema, - }, async (args: { title: string }) => { - const response = await handler(args.title); - logger.debug('[hapiMCP] Response:', response); - - if (response.success) { - return { - content: [ - { - type: 'text' as const, - text: `Successfully changed chat title to: "${args.title}"`, - }, - ], - isError: false, - }; - } else { - return { - content: [ - { - type: 'text' as const, - text: `Failed to change chat title: ${response.error || 'Unknown error'}`, - }, - ], - isError: true, - }; - } - }); + if (shouldEnableAutoTitle()) { + const changeTitleInputSchema: z.ZodTypeAny = z.object({ + title: z.string().describe('The new title for the chat session'), + }); + + mcp.registerTool('change_title', { + description: 'Change the title of the current chat session', + title: 'Change Chat Title', + inputSchema: changeTitleInputSchema, + }, async (args: { title: string }) => { + const response = await handler(args.title); + logger.debug('[hapiMCP] Response:', response); + + if (response.success) { + return { + content: [ + { + type: 'text' as const, + text: `Successfully changed chat title to: "${args.title}"`, + }, + ], + isError: false, + }; + } else { + return { + content: [ + { + type: 'text' as const, + text: `Failed to change chat title: ${response.error || 'Unknown error'}`, + }, + ], + isError: true, + }; + } + }); + + toolNames.push('change_title'); + } const transport = new StreamableHTTPServerTransport({ // NOTE: Returning session id here will result in claude @@ -106,7 +112,7 @@ export async function startHappyServer(client: ApiSessionClient) { return { url: baseUrl.toString(), - toolNames: ['change_title'], + toolNames, stop: () => { logger.debug('[hapiMCP] Stopping server'); mcp.close(); diff --git a/cli/src/claude/utils/systemPrompt.ts b/cli/src/claude/utils/systemPrompt.ts index 502fff43b..e8225447c 100644 --- a/cli/src/claude/utils/systemPrompt.ts +++ b/cli/src/claude/utils/systemPrompt.ts @@ -1,16 +1,10 @@ import { trimIdent } from "@/utils/trimIdent"; -import { shouldIncludeCoAuthoredBy } from "./claudeSettings"; +import { shouldEnableAutoTitle, shouldIncludeCoAuthoredBy } from "./claudeSettings"; -/** - * Base system prompt shared across all configurations - */ -const BASE_SYSTEM_PROMPT = (() => trimIdent(` +const TITLE_PROMPT = (() => trimIdent(` ALWAYS when you start a new chat - you must call a tool "mcp__hapi__change_title" to set a chat title. When you think chat title is not relevant anymore - call the tool again to change it. When chat name is too generic and you have a change to make it more specific - call the tool again to change it. This title is needed to easily find the chat in the future. Help human. `))(); -/** - * Co-authored-by credits to append when enabled - */ const CO_AUTHORED_CREDITS = (() => trimIdent(` When making commit messages, you SHOULD also give credit to HAPI like so: @@ -21,16 +15,13 @@ const CO_AUTHORED_CREDITS = (() => trimIdent(` Co-Authored-By: HAPI `))(); -/** - * System prompt with conditional Co-Authored-By lines based on Claude's settings.json configuration. - * Settings are read once on startup for performance. - */ export const systemPrompt = (() => { - const includeCoAuthored = shouldIncludeCoAuthoredBy(); - - if (includeCoAuthored) { - return BASE_SYSTEM_PROMPT + '\n\n' + CO_AUTHORED_CREDITS; - } else { - return BASE_SYSTEM_PROMPT; + const parts: string[] = []; + if (shouldEnableAutoTitle()) { + parts.push(TITLE_PROMPT); } + if (shouldIncludeCoAuthoredBy()) { + parts.push(CO_AUTHORED_CREDITS); + } + return parts.join('\n\n'); })(); diff --git a/cli/src/codex/happyMcpStdioBridge.ts b/cli/src/codex/happyMcpStdioBridge.ts index 8ef0b829f..f2f9316a6 100644 --- a/cli/src/codex/happyMcpStdioBridge.ts +++ b/cli/src/codex/happyMcpStdioBridge.ts @@ -16,6 +16,7 @@ import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js' import { Client } from '@modelcontextprotocol/sdk/client/index.js'; import { StreamableHTTPClientTransport } from '@modelcontextprotocol/sdk/client/streamableHttp.js'; import { z } from 'zod'; +import { shouldEnableAutoTitle } from '@/claude/utils/claudeSettings'; function parseArgs(argv: string[]): { url: string | null } { let url: string | null = null; @@ -64,34 +65,34 @@ export async function runHappyMcpStdioBridge(argv: string[]): Promise { version: '1.0.0', }); - // Register the single tool and forward to HTTP MCP - const changeTitleInputSchema: z.ZodTypeAny = z.object({ - title: z.string().describe('The new title for the chat session'), - }); + if (shouldEnableAutoTitle()) { + const changeTitleInputSchema: z.ZodTypeAny = z.object({ + title: z.string().describe('The new title for the chat session'), + }); - server.registerTool( - 'change_title', - { - description: 'Change the title of the current chat session', - title: 'Change Chat Title', - inputSchema: changeTitleInputSchema, - }, - async (args: Record) => { - try { - const client = await ensureHttpClient(); - const response = await client.callTool({ name: 'change_title', arguments: args }); - // Pass-through response from HTTP server - return response as any; - } catch (error) { - return { - content: [ - { type: 'text' as const, text: `Failed to change chat title: ${error instanceof Error ? error.message : String(error)}` }, - ], - isError: true, - }; + server.registerTool( + 'change_title', + { + description: 'Change the title of the current chat session', + title: 'Change Chat Title', + inputSchema: changeTitleInputSchema, + }, + async (args: Record) => { + try { + const client = await ensureHttpClient(); + const response = await client.callTool({ name: 'change_title', arguments: args }); + return response as any; + } catch (error) { + return { + content: [ + { type: 'text' as const, text: `Failed to change chat title: ${error instanceof Error ? error.message : String(error)}` }, + ], + isError: true, + }; + } } - } - ); + ); + } // Start STDIO transport const stdio = new StdioServerTransport(); diff --git a/cli/src/codex/utils/systemPrompt.ts b/cli/src/codex/utils/systemPrompt.ts index c8be66202..7934ececd 100644 --- a/cli/src/codex/utils/systemPrompt.ts +++ b/cli/src/codex/utils/systemPrompt.ts @@ -1,25 +1,13 @@ -/** - * Codex-specific system prompt for local mode. - * - * This prompt instructs Codex to call the hapi__change_title function - * to set appropriate chat session titles. - */ - import { trimIdent } from '@/utils/trimIdent'; +import { shouldEnableAutoTitle } from '@/claude/utils/claudeSettings'; -/** - * Title instruction for Codex to call the hapi MCP tool. - * Note: Codex exposes MCP tools under the `functions.` namespace, - * so the tool is called as `functions.hapi__change_title`. - */ -export const TITLE_INSTRUCTION = trimIdent(` +export const TITLE_INSTRUCTION = shouldEnableAutoTitle() + ? trimIdent(` ALWAYS when you start a new chat, call the title tool to set a concise task title. Prefer calling functions.hapi__change_title. If that exact tool name is unavailable, call an equivalent alias such as hapi__change_title, mcp__hapi__change_title, or hapi_change_title. If the task focus changes significantly later, call the title tool again with a better title. -`); +`) + : ''; -/** - * The system prompt to inject via developer_instructions in local mode. - */ export const codexSystemPrompt = TITLE_INSTRUCTION; diff --git a/cli/src/opencode/utils/systemPrompt.ts b/cli/src/opencode/utils/systemPrompt.ts index 97dc0084c..3ad933376 100644 --- a/cli/src/opencode/utils/systemPrompt.ts +++ b/cli/src/opencode/utils/systemPrompt.ts @@ -1,20 +1,10 @@ -/** - * OpenCode-specific system prompt for change_title tool. - * - * OpenCode exposes MCP tools with the naming pattern: _ - * The hapi MCP server exposes `change_title`, so it's called as `hapi_change_title`. - */ - import { trimIdent } from '@/utils/trimIdent'; +import { shouldEnableAutoTitle } from '@/claude/utils/claudeSettings'; -/** - * Title instruction for OpenCode to call the hapi MCP tool. - */ -export const TITLE_INSTRUCTION = trimIdent(` +export const TITLE_INSTRUCTION = shouldEnableAutoTitle() + ? trimIdent(` ALWAYS when you start a new chat - you must call the tool "hapi_change_title" to set a chat title. When you think chat title is not relevant anymore - call the tool again to change it. When chat name is too generic and you have a chance to make it more specific - call the tool again to change it. This title is needed to easily find the chat in the future. Help human. -`); +`) + : ''; -/** - * The system prompt to inject for OpenCode sessions. - */ export const opencodeSystemPrompt = TITLE_INSTRUCTION; diff --git a/cli/src/persistence.ts b/cli/src/persistence.ts index 2a4f09869..50d469302 100644 --- a/cli/src/persistence.ts +++ b/cli/src/persistence.ts @@ -21,6 +21,7 @@ interface Settings { apiUrl?: string // Legacy field name (for migration, read-only) serverUrl?: string + enableAutoTitle?: boolean } const defaultSettings: Settings = {} @@ -55,6 +56,19 @@ export async function readSettings(): Promise { } } +export function readSettingsSync(): Settings { + if (!existsSync(configuration.settingsFile)) { + return { ...defaultSettings } + } + + try { + const content = readFileSync(configuration.settingsFile, 'utf-8') + return JSON.parse(content) + } catch { + return { ...defaultSettings } + } +} + export async function writeSettings(settings: Settings): Promise { if (!existsSync(configuration.happyHomeDir)) { await mkdir(configuration.happyHomeDir, { recursive: true }) diff --git a/docs/public/schemas/settings.schema.json b/docs/public/schemas/settings.schema.json index b1f5556bc..bec1e692a 100644 --- a/docs/public/schemas/settings.schema.json +++ b/docs/public/schemas/settings.schema.json @@ -72,6 +72,11 @@ "runnerAutoStartWhenRunningHappy": { "type": "boolean", "description": "Auto-start runner when running hapi command." + }, + "enableAutoTitle": { + "type": "boolean", + "default": true, + "description": "Enable automatic session title changes by the agent. When false, the change_title tool is not registered and the title instruction is omitted from system prompts." } }, "additionalProperties": false