takt: github-issue-101-itereeshon-ni (#137)
This commit is contained in:
parent
b9a2a0329b
commit
d9ab76f08b
@ -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
|
||||
|
||||
@ -37,6 +37,9 @@ provider: claude
|
||||
# {issue_body}
|
||||
# Closes #{issue}
|
||||
|
||||
# 通知音 (true: 有効 / false: 無効、デフォルト: true)
|
||||
# notification_sound: true
|
||||
|
||||
# デバッグ設定 (オプション)
|
||||
# debug:
|
||||
# enabled: false
|
||||
|
||||
@ -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');
|
||||
|
||||
353
src/__tests__/it-notification-sound.test.ts
Normal file
353
src/__tests__/it-notification-sound.test.ts
Normal file
@ -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<number | null>) | undefined;
|
||||
|
||||
constructor(
|
||||
_config: unknown,
|
||||
_cwd: string,
|
||||
_task: string,
|
||||
options: { onIterationLimit?: (req: unknown) => Promise<number | null> },
|
||||
) {
|
||||
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<void> {
|
||||
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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@ -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(),
|
||||
}));
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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 */
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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();
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user