diff --git a/builtins/en/config.yaml b/builtins/en/config.yaml index ae75306..9299c89 100644 --- a/builtins/en/config.yaml +++ b/builtins/en/config.yaml @@ -37,6 +37,9 @@ provider: claude # {issue_body} # Closes #{issue} +# Notification sounds (true: enabled, false: disabled, default: true) +# notification_sound: true + # Debug settings (optional) # debug: # enabled: false diff --git a/builtins/ja/config.yaml b/builtins/ja/config.yaml index 341103c..4ea789b 100644 --- a/builtins/ja/config.yaml +++ b/builtins/ja/config.yaml @@ -37,6 +37,9 @@ provider: claude # {issue_body} # Closes #{issue} +# 通知音 (true: 有効 / false: 無効、デフォルト: true) +# notification_sound: true + # デバッグ設定 (オプション) # debug: # enabled: false diff --git a/src/__tests__/globalConfig-defaults.test.ts b/src/__tests__/globalConfig-defaults.test.ts index 2a6b159..5cc39ff 100644 --- a/src/__tests__/globalConfig-defaults.test.ts +++ b/src/__tests__/globalConfig-defaults.test.ts @@ -241,6 +241,52 @@ describe('loadGlobalConfig', () => { expect(config.preventSleep).toBeUndefined(); }); + it('should load notification_sound config from config.yaml', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync( + getGlobalConfigPath(), + 'language: en\nnotification_sound: false\n', + 'utf-8', + ); + + const config = loadGlobalConfig(); + expect(config.notificationSound).toBe(false); + }); + + it('should save and reload notification_sound config', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); + + const config = loadGlobalConfig(); + config.notificationSound = true; + saveGlobalConfig(config); + invalidateGlobalConfigCache(); + + const reloaded = loadGlobalConfig(); + expect(reloaded.notificationSound).toBe(true); + }); + + it('should save notification_sound: false when explicitly set', () => { + const taktDir = join(testHomeDir, '.takt'); + mkdirSync(taktDir, { recursive: true }); + writeFileSync(getGlobalConfigPath(), 'language: en\n', 'utf-8'); + + const config = loadGlobalConfig(); + config.notificationSound = false; + saveGlobalConfig(config); + invalidateGlobalConfigCache(); + + const reloaded = loadGlobalConfig(); + expect(reloaded.notificationSound).toBe(false); + }); + + it('should have undefined notificationSound by default', () => { + const config = loadGlobalConfig(); + expect(config.notificationSound).toBeUndefined(); + }); + describe('provider/model compatibility validation', () => { it('should throw when provider is codex but model is a Claude alias (opus)', () => { const taktDir = join(testHomeDir, '.takt'); diff --git a/src/__tests__/it-notification-sound.test.ts b/src/__tests__/it-notification-sound.test.ts new file mode 100644 index 0000000..6de13de --- /dev/null +++ b/src/__tests__/it-notification-sound.test.ts @@ -0,0 +1,353 @@ +/** + * Integration test: notification sound ON/OFF in executePiece(). + * + * Verifies that: + * - notificationSound: undefined (default) → playWarningSound / notifySuccess / notifyError are called + * - notificationSound: true → playWarningSound / notifySuccess / notifyError are called + * - notificationSound: false → playWarningSound / notifySuccess / notifyError are NOT called + */ + +import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest'; +import { existsSync, rmSync, mkdirSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { randomUUID } from 'node:crypto'; + +// --- Hoisted mocks (must be before vi.mock calls) --- + +const { + MockPieceEngine, + mockInterruptAllQueries, + mockLoadGlobalConfig, + mockNotifySuccess, + mockNotifyError, + mockPlayWarningSound, + mockSelectOption, +} = vi.hoisted(() => { + // eslint-disable-next-line @typescript-eslint/no-require-imports + const { EventEmitter: EE } = require('node:events') as typeof import('node:events'); + + const mockInterruptAllQueries = vi.fn().mockReturnValue(0); + const mockLoadGlobalConfig = vi.fn().mockReturnValue({ provider: 'claude' }); + const mockNotifySuccess = vi.fn(); + const mockNotifyError = vi.fn(); + const mockPlayWarningSound = vi.fn(); + const mockSelectOption = vi.fn().mockResolvedValue('stop'); + + // Mock PieceEngine that can simulate complete / abort / iteration-limit + class MockPieceEngine extends EE { + static latestInstance: MockPieceEngine | null = null; + + private runResolve: ((value: { status: string; iteration: number }) => void) | null = null; + private onIterationLimit: ((req: unknown) => Promise) | undefined; + + constructor( + _config: unknown, + _cwd: string, + _task: string, + options: { onIterationLimit?: (req: unknown) => Promise }, + ) { + super(); + this.onIterationLimit = options?.onIterationLimit; + MockPieceEngine.latestInstance = this; + } + + abort(): void { + const state = { status: 'aborted', iteration: 1 }; + this.emit('piece:abort', state, 'user_interrupted'); + if (this.runResolve) { + this.runResolve(state); + this.runResolve = null; + } + } + + complete(): void { + const state = { status: 'completed', iteration: 3 }; + this.emit('piece:complete', state); + if (this.runResolve) { + this.runResolve(state); + this.runResolve = null; + } + } + + async triggerIterationLimit(): Promise { + if (this.onIterationLimit) { + await this.onIterationLimit({ + currentIteration: 10, + maxIterations: 10, + currentMovement: 'step1', + }); + } + } + + async run(): Promise<{ status: string; iteration: number }> { + return new Promise((resolve) => { + this.runResolve = resolve; + }); + } + } + + return { + MockPieceEngine, + mockInterruptAllQueries, + mockLoadGlobalConfig, + mockNotifySuccess, + mockNotifyError, + mockPlayWarningSound, + mockSelectOption, + }; +}); + +// --- Module mocks --- + +vi.mock('../core/piece/index.js', () => ({ + PieceEngine: MockPieceEngine, +})); + +vi.mock('../infra/claude/index.js', () => ({ + callAiJudge: vi.fn(), + detectRuleIndex: vi.fn(), + interruptAllQueries: mockInterruptAllQueries, +})); + +vi.mock('../infra/config/index.js', () => ({ + loadPersonaSessions: vi.fn().mockReturnValue({}), + updatePersonaSession: vi.fn(), + loadWorktreeSessions: vi.fn().mockReturnValue({}), + updateWorktreeSession: vi.fn(), + loadGlobalConfig: mockLoadGlobalConfig, + saveSessionState: vi.fn(), +})); + +vi.mock('../shared/context.js', () => ({ + isQuietMode: vi.fn().mockReturnValue(true), +})); + +vi.mock('../shared/ui/index.js', () => ({ + header: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + success: vi.fn(), + status: vi.fn(), + blankLine: vi.fn(), + StreamDisplay: vi.fn().mockImplementation(() => ({ + createHandler: vi.fn().mockReturnValue(vi.fn()), + flush: vi.fn(), + })), +})); + +vi.mock('../infra/fs/index.js', () => ({ + generateSessionId: vi.fn().mockReturnValue('test-session-id'), + createSessionLog: vi.fn().mockReturnValue({ + startTime: new Date().toISOString(), + iterations: 0, + }), + finalizeSessionLog: vi.fn().mockImplementation((log, _status) => ({ + ...log, + status: _status, + endTime: new Date().toISOString(), + })), + updateLatestPointer: vi.fn(), + initNdjsonLog: vi.fn().mockReturnValue('/tmp/test-log.jsonl'), + appendNdjsonLine: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', () => ({ + createLogger: vi.fn().mockReturnValue({ + debug: vi.fn(), + info: vi.fn(), + warn: vi.fn(), + error: vi.fn(), + }), + notifySuccess: mockNotifySuccess, + notifyError: mockNotifyError, + playWarningSound: mockPlayWarningSound, + preventSleep: vi.fn(), +})); + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: mockSelectOption, + promptInput: vi.fn(), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn().mockImplementation((key: string) => key), +})); + +vi.mock('../shared/exitCodes.js', () => ({ + EXIT_SIGINT: 130, +})); + +// --- Import under test (after mocks) --- + +import { executePiece } from '../features/tasks/execute/pieceExecution.js'; +import type { PieceConfig } from '../core/models/index.js'; + +// --- Helpers --- + +function makeConfig(): PieceConfig { + return { + name: 'test-notify', + maxIterations: 10, + initialMovement: 'step1', + movements: [ + { + name: 'step1', + persona: '../agents/coder.md', + personaDisplayName: 'coder', + instructionTemplate: 'Do something', + passPreviousResponse: true, + rules: [ + { condition: 'done', next: 'COMPLETE' }, + { condition: 'fail', next: 'ABORT' }, + ], + }, + ], + }; +} + +// --- Tests --- + +describe('executePiece: notification sound behavior', () => { + let tmpDir: string; + let savedSigintListeners: ((...args: unknown[]) => void)[]; + + beforeEach(() => { + vi.clearAllMocks(); + MockPieceEngine.latestInstance = null; + tmpDir = join(tmpdir(), `takt-notify-it-${randomUUID()}`); + mkdirSync(tmpDir, { recursive: true }); + mkdirSync(join(tmpDir, '.takt', 'reports'), { recursive: true }); + + savedSigintListeners = process.rawListeners('SIGINT') as ((...args: unknown[]) => void)[]; + }); + + afterEach(() => { + if (existsSync(tmpDir)) { + rmSync(tmpDir, { recursive: true, force: true }); + } + process.removeAllListeners('SIGINT'); + for (const listener of savedSigintListeners) { + process.on('SIGINT', listener as NodeJS.SignalsListener); + } + process.removeAllListeners('uncaughtException'); + }); + + describe('notifySuccess on piece:complete', () => { + it('should call notifySuccess when notificationSound is undefined (default)', async () => { + mockLoadGlobalConfig.mockReturnValue({ provider: 'claude' }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + MockPieceEngine.latestInstance!.complete(); + await resultPromise; + + expect(mockNotifySuccess).toHaveBeenCalledOnce(); + }); + + it('should call notifySuccess when notificationSound is true', async () => { + mockLoadGlobalConfig.mockReturnValue({ provider: 'claude', notificationSound: true }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + MockPieceEngine.latestInstance!.complete(); + await resultPromise; + + expect(mockNotifySuccess).toHaveBeenCalledOnce(); + }); + + it('should NOT call notifySuccess when notificationSound is false', async () => { + mockLoadGlobalConfig.mockReturnValue({ provider: 'claude', notificationSound: false }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + MockPieceEngine.latestInstance!.complete(); + await resultPromise; + + expect(mockNotifySuccess).not.toHaveBeenCalled(); + }); + }); + + describe('notifyError on piece:abort', () => { + it('should call notifyError when notificationSound is undefined (default)', async () => { + mockLoadGlobalConfig.mockReturnValue({ provider: 'claude' }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + MockPieceEngine.latestInstance!.abort(); + await resultPromise; + + expect(mockNotifyError).toHaveBeenCalledOnce(); + }); + + it('should call notifyError when notificationSound is true', async () => { + mockLoadGlobalConfig.mockReturnValue({ provider: 'claude', notificationSound: true }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + MockPieceEngine.latestInstance!.abort(); + await resultPromise; + + expect(mockNotifyError).toHaveBeenCalledOnce(); + }); + + it('should NOT call notifyError when notificationSound is false', async () => { + mockLoadGlobalConfig.mockReturnValue({ provider: 'claude', notificationSound: false }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + MockPieceEngine.latestInstance!.abort(); + await resultPromise; + + expect(mockNotifyError).not.toHaveBeenCalled(); + }); + }); + + describe('playWarningSound on iteration limit', () => { + it('should call playWarningSound when notificationSound is undefined (default)', async () => { + mockLoadGlobalConfig.mockReturnValue({ provider: 'claude' }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + await MockPieceEngine.latestInstance!.triggerIterationLimit(); + MockPieceEngine.latestInstance!.abort(); + await resultPromise; + + expect(mockPlayWarningSound).toHaveBeenCalledOnce(); + }); + + it('should call playWarningSound when notificationSound is true', async () => { + mockLoadGlobalConfig.mockReturnValue({ provider: 'claude', notificationSound: true }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + await MockPieceEngine.latestInstance!.triggerIterationLimit(); + MockPieceEngine.latestInstance!.abort(); + await resultPromise; + + expect(mockPlayWarningSound).toHaveBeenCalledOnce(); + }); + + it('should NOT call playWarningSound when notificationSound is false', async () => { + mockLoadGlobalConfig.mockReturnValue({ provider: 'claude', notificationSound: false }); + + const resultPromise = executePiece(makeConfig(), 'test task', tmpDir, { projectCwd: tmpDir }); + await new Promise((resolve) => setTimeout(resolve, 10)); + + await MockPieceEngine.latestInstance!.triggerIterationLimit(); + MockPieceEngine.latestInstance!.abort(); + await resultPromise; + + expect(mockPlayWarningSound).not.toHaveBeenCalled(); + }); + }); +}); diff --git a/src/__tests__/it-sigint-interrupt.test.ts b/src/__tests__/it-sigint-interrupt.test.ts index 7c0944a..ab3d86b 100644 --- a/src/__tests__/it-sigint-interrupt.test.ts +++ b/src/__tests__/it-sigint-interrupt.test.ts @@ -128,6 +128,8 @@ vi.mock('../shared/utils/index.js', () => ({ }), notifySuccess: vi.fn(), notifyError: vi.fn(), + playWarningSound: vi.fn(), + preventSleep: vi.fn(), isDebugEnabled: vi.fn().mockReturnValue(false), writePromptLog: vi.fn(), })); diff --git a/src/core/models/global-config.ts b/src/core/models/global-config.ts index 62ddfdd..38d39c6 100644 --- a/src/core/models/global-config.ts +++ b/src/core/models/global-config.ts @@ -65,6 +65,8 @@ export interface GlobalConfig { branchNameStrategy?: 'romaji' | 'ai'; /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ preventSleep?: boolean; + /** Enable notification sounds (default: true when undefined) */ + notificationSound?: boolean; } /** Project-level configuration */ diff --git a/src/core/models/schemas.ts b/src/core/models/schemas.ts index 0a7ef18..006a512 100644 --- a/src/core/models/schemas.ts +++ b/src/core/models/schemas.ts @@ -311,6 +311,8 @@ export const GlobalConfigSchema = z.object({ branch_name_strategy: z.enum(['romaji', 'ai']).optional(), /** Prevent macOS idle sleep during takt execution using caffeinate (default: false) */ prevent_sleep: z.boolean().optional(), + /** Enable notification sounds (default: true when undefined) */ + notification_sound: z.boolean().optional(), }); /** Project config schema */ diff --git a/src/features/tasks/execute/pieceExecution.ts b/src/features/tasks/execute/pieceExecution.ts index 049ea2b..28ac91e 100644 --- a/src/features/tasks/execute/pieceExecution.ts +++ b/src/features/tasks/execute/pieceExecution.ts @@ -51,6 +51,7 @@ import { notifySuccess, notifyError, preventSleep, + playWarningSound, isDebugEnabled, writePromptLog, } from '../../../shared/utils/index.js'; @@ -150,6 +151,7 @@ export async function executePiece( // Load saved agent sessions for continuity (from project root or clone-specific storage) const isWorktree = cwd !== projectCwd; const globalConfig = loadGlobalConfig(); + const shouldNotify = globalConfig.notificationSound !== false; const currentProvider = globalConfig.provider ?? 'claude'; // Prevent macOS idle sleep if configured @@ -187,6 +189,10 @@ export async function executePiece( ); info(getLabel('piece.iterationLimit.currentMovement', undefined, { currentMovement: request.currentMovement })); + if (shouldNotify) { + playWarningSound(); + } + const action = await selectOption(getLabel('piece.iterationLimit.continueQuestion'), [ { label: getLabel('piece.iterationLimit.continueLabel'), @@ -439,7 +445,9 @@ export async function executePiece( success(`Piece completed (${state.iteration} iterations${elapsedDisplay})`); info(`Session log: ${ndjsonLogPath}`); - notifySuccess('TAKT', getLabel('piece.notifyComplete', undefined, { iteration: String(state.iteration) })); + if (shouldNotify) { + notifySuccess('TAKT', getLabel('piece.notifyComplete', undefined, { iteration: String(state.iteration) })); + } }); engine.on('piece:abort', (state, reason) => { @@ -484,7 +492,9 @@ export async function executePiece( error(`Piece aborted after ${state.iteration} iterations${elapsedDisplay}: ${reason}`); info(`Session log: ${ndjsonLogPath}`); - notifyError('TAKT', getLabel('piece.notifyAbort', undefined, { reason })); + if (shouldNotify) { + notifyError('TAKT', getLabel('piece.notifyAbort', undefined, { reason })); + } }); // Suppress EPIPE errors from SDK child process stdin after interrupt. diff --git a/src/infra/config/global/globalConfig.ts b/src/infra/config/global/globalConfig.ts index 9d503a4..0c390a5 100644 --- a/src/infra/config/global/globalConfig.ts +++ b/src/infra/config/global/globalConfig.ts @@ -105,6 +105,7 @@ export class GlobalConfigManager { pieceCategoriesFile: parsed.piece_categories_file, branchNameStrategy: parsed.branch_name_strategy, preventSleep: parsed.prevent_sleep, + notificationSound: parsed.notification_sound, }; validateProviderModelCompatibility(config.provider, config.model); this.cachedConfig = config; @@ -171,6 +172,9 @@ export class GlobalConfigManager { if (config.preventSleep !== undefined) { raw.prevent_sleep = config.preventSleep; } + if (config.notificationSound !== undefined) { + raw.notification_sound = config.notificationSound; + } writeFileSync(configPath, stringifyYaml(raw), 'utf-8'); this.invalidateCache(); }