This commit is contained in:
nrslib 2026-02-08 07:27:46 +09:00
commit 7e01260196
16 changed files with 783 additions and 21 deletions

View File

@ -145,6 +145,110 @@ describe('StreamDisplay', () => {
}); });
}); });
describe('ANSI escape sequence stripping', () => {
it('should strip ANSI codes from text before writing to stdout', () => {
const display = new StreamDisplay('test-agent', false);
display.showText('\x1b[41mRed background\x1b[0m');
expect(stdoutWriteSpy).toHaveBeenCalledWith('Red background');
});
it('should strip ANSI codes from thinking before writing to stdout', () => {
const display = new StreamDisplay('test-agent', false);
display.showThinking('\x1b[31mColored thinking\x1b[0m');
// chalk.gray.italic wraps the stripped text, so check it does NOT contain raw ANSI
const writtenText = stdoutWriteSpy.mock.calls[0]?.[0] as string;
expect(writtenText).not.toContain('\x1b[41m');
expect(writtenText).not.toContain('\x1b[31m');
expect(writtenText).toContain('Colored thinking');
});
it('should accumulate stripped text in textBuffer', () => {
const display = new StreamDisplay('test-agent', false);
display.showText('\x1b[31mRed\x1b[0m');
display.showText('\x1b[32m Green\x1b[0m');
// Flush should work correctly with stripped content
display.flushText();
// After flush, buffer is cleared — verify no crash and text was output
expect(stdoutWriteSpy).toHaveBeenCalledWith('Red');
expect(stdoutWriteSpy).toHaveBeenCalledWith(' Green');
});
it('should accumulate stripped text in thinkingBuffer', () => {
const display = new StreamDisplay('test-agent', false);
display.showThinking('\x1b[31mThought 1\x1b[0m');
display.showThinking('\x1b[32m Thought 2\x1b[0m');
display.flushThinking();
// Verify stripped text was written (wrapped in chalk styling)
expect(stdoutWriteSpy).toHaveBeenCalledTimes(2);
});
it('should not strip ANSI from text that has no ANSI codes', () => {
const display = new StreamDisplay('test-agent', false);
display.showText('Plain text');
expect(stdoutWriteSpy).toHaveBeenCalledWith('Plain text');
});
it('should strip ANSI codes from tool output before buffering', () => {
const display = new StreamDisplay('test-agent', false);
display.showToolUse('Bash', { command: 'ls' });
display.showToolOutput('\x1b[32mgreen output\x1b[0m\n');
const outputLine = consoleLogSpy.mock.calls.find(
(call) => typeof call[0] === 'string' && (call[0] as string).includes('green output'),
);
expect(outputLine).toBeDefined();
expect(outputLine![0]).not.toContain('\x1b[32m');
});
it('should strip ANSI codes from tool output across multiple chunks', () => {
const display = new StreamDisplay('test-agent', false);
display.showToolUse('Bash', { command: 'ls' });
display.showToolOutput('\x1b[31mpartial');
display.showToolOutput(' line\x1b[0m\n');
const outputLine = consoleLogSpy.mock.calls.find(
(call) => typeof call[0] === 'string' && (call[0] as string).includes('partial line'),
);
expect(outputLine).toBeDefined();
expect(outputLine![0]).not.toContain('\x1b[31m');
});
it('should strip ANSI codes from tool result content', () => {
const display = new StreamDisplay('test-agent', false);
display.showToolUse('Read', { file_path: '/test.ts' });
display.showToolResult('\x1b[41mResult with red bg\x1b[0m', false);
const resultLine = consoleLogSpy.mock.calls.find(
(call) => typeof call[0] === 'string' && (call[0] as string).includes('✓'),
);
expect(resultLine).toBeDefined();
const fullOutput = resultLine!.join(' ');
expect(fullOutput).toContain('Result with red bg');
expect(fullOutput).not.toContain('\x1b[41m');
});
it('should strip ANSI codes from tool result error content', () => {
const display = new StreamDisplay('test-agent', false);
display.showToolUse('Bash', { command: 'fail' });
display.showToolResult('\x1b[31mError message\x1b[0m', true);
const errorLine = consoleLogSpy.mock.calls.find(
(call) => typeof call[0] === 'string' && (call[0] as string).includes('✗'),
);
expect(errorLine).toBeDefined();
const fullOutput = errorLine!.join(' ');
expect(fullOutput).toContain('Error message');
expect(fullOutput).not.toContain('\x1b[31m');
});
});
describe('progress prefix format', () => { describe('progress prefix format', () => {
it('should format progress as (iteration/max) step index/total', () => { it('should format progress as (iteration/max) step index/total', () => {
const progressInfo: ProgressInfo = { const progressInfo: ProgressInfo = {

View File

@ -386,6 +386,23 @@ describe('addTask', () => {
expect(mockResolveIssueTask).toHaveBeenCalledWith('#99'); expect(mockResolveIssueTask).toHaveBeenCalledWith('#99');
}); });
it('should call auto-PR confirm with default true', async () => {
// Given: worktree is confirmed so auto-PR prompt is reached
setupFullFlowMocks({ slug: 'auto-pr-default' });
mockConfirm.mockResolvedValue(true);
mockPromptInput.mockResolvedValue('');
// When
await addTask(testDir);
// Then: second confirm call (Auto-create PR?) has defaultYes=true
const autoPrCall = mockConfirm.mock.calls.find(
(call) => call[0] === 'Auto-create PR?',
);
expect(autoPrCall).toBeDefined();
expect(autoPrCall![1]).toBe(true);
});
describe('create_issue action', () => { describe('create_issue action', () => {
it('should call createIssue when create_issue action is selected', async () => { it('should call createIssue when create_issue action is selected', async () => {
// Given: interactive mode returns create_issue action // Given: interactive mode returns create_issue action

View File

@ -14,11 +14,21 @@ import {
debugLog, debugLog,
infoLog, infoLog,
errorLog, errorLog,
writePromptLog,
} from '../shared/utils/index.js'; } from '../shared/utils/index.js';
import { existsSync, readFileSync, mkdirSync, rmSync } from 'node:fs'; import { existsSync, readFileSync, mkdirSync, rmSync } from 'node:fs';
import { join } from 'node:path'; import { join } from 'node:path';
import { tmpdir } from 'node:os'; import { tmpdir } from 'node:os';
function resolvePromptsLogFilePath(): string {
const debugLogFile = getDebugLogFile();
expect(debugLogFile).not.toBeNull();
if (!debugLogFile!.endsWith('.log')) {
throw new Error(`unexpected debug log file path: ${debugLogFile!}`);
}
return debugLogFile!.replace(/\.log$/, '-prompts.jsonl');
}
describe('debug logging', () => { describe('debug logging', () => {
beforeEach(() => { beforeEach(() => {
resetDebugLogger(); resetDebugLogger();
@ -69,6 +79,21 @@ describe('debug logging', () => {
} }
}); });
it('should create prompts log file with -prompts suffix', () => {
const projectDir = join(tmpdir(), 'takt-test-debug-prompts-' + Date.now());
mkdirSync(projectDir, { recursive: true });
try {
initDebugLogger({ enabled: true }, projectDir);
const promptsLogFile = resolvePromptsLogFilePath();
expect(promptsLogFile).toContain(join(projectDir, '.takt', 'logs'));
expect(promptsLogFile).toMatch(/debug-.*-prompts\.jsonl$/);
expect(existsSync(promptsLogFile)).toBe(true);
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
it('should not create log file when projectDir is not provided', () => { it('should not create log file when projectDir is not provided', () => {
initDebugLogger({ enabled: true }); initDebugLogger({ enabled: true });
expect(isDebugEnabled()).toBe(true); expect(isDebugEnabled()).toBe(true);
@ -83,7 +108,9 @@ describe('debug logging', () => {
try { try {
initDebugLogger({ enabled: true, logFile }, '/tmp'); initDebugLogger({ enabled: true, logFile }, '/tmp');
expect(getDebugLogFile()).toBe(logFile); expect(getDebugLogFile()).toBe(logFile);
expect(resolvePromptsLogFilePath()).toBe(join(logDir, 'test-prompts.jsonl'));
expect(existsSync(logFile)).toBe(true); expect(existsSync(logFile)).toBe(true);
expect(existsSync(join(logDir, 'test-prompts.jsonl'))).toBe(true);
const content = readFileSync(logFile, 'utf-8'); const content = readFileSync(logFile, 'utf-8');
expect(content).toContain('TAKT Debug Log'); expect(content).toContain('TAKT Debug Log');
@ -122,6 +149,59 @@ describe('debug logging', () => {
}); });
}); });
describe('writePromptLog', () => {
it('should append prompt log record when debug is enabled', () => {
const projectDir = join(tmpdir(), 'takt-test-debug-write-prompts-' + Date.now());
mkdirSync(projectDir, { recursive: true });
try {
initDebugLogger({ enabled: true }, projectDir);
const promptsLogFile = resolvePromptsLogFilePath();
writePromptLog({
movement: 'plan',
phase: 1,
iteration: 2,
prompt: 'prompt text',
response: 'response text',
timestamp: '2026-02-07T00:00:00.000Z',
});
const content = readFileSync(promptsLogFile, 'utf-8').trim();
expect(content).not.toBe('');
const parsed = JSON.parse(content) as {
movement: string;
phase: number;
iteration: number;
prompt: string;
response: string;
timestamp: string;
};
expect(parsed.movement).toBe('plan');
expect(parsed.phase).toBe(1);
expect(parsed.iteration).toBe(2);
expect(parsed.prompt).toBe('prompt text');
expect(parsed.response).toBe('response text');
expect(parsed.timestamp).toBe('2026-02-07T00:00:00.000Z');
} finally {
rmSync(projectDir, { recursive: true, force: true });
}
});
it('should do nothing when debug is disabled', () => {
writePromptLog({
movement: 'plan',
phase: 1,
iteration: 1,
prompt: 'ignored prompt',
response: 'ignored response',
timestamp: '2026-02-07T00:00:00.000Z',
});
expect(getDebugLogFile()).toBeNull();
});
});
describe('setVerboseConsole / isVerboseConsole', () => { describe('setVerboseConsole / isVerboseConsole', () => {
it('should default to false', () => { it('should default to false', () => {
expect(isVerboseConsole()).toBe(false); expect(isVerboseConsole()).toBe(false);

View File

@ -128,6 +128,8 @@ vi.mock('../shared/utils/index.js', () => ({
}), }),
notifySuccess: vi.fn(), notifySuccess: vi.fn(),
notifyError: vi.fn(), notifyError: vi.fn(),
isDebugEnabled: vi.fn().mockReturnValue(false),
writePromptLog: vi.fn(),
})); }));
vi.mock('../shared/prompt/index.js', () => ({ vi.mock('../shared/prompt/index.js', () => ({

View File

@ -392,6 +392,90 @@ describe('ParallelLogger', () => {
}); });
}); });
describe('ANSI escape sequence stripping', () => {
it('should strip ANSI codes from text events', () => {
const logger = new ParallelLogger({
subMovementNames: ['step-a'],
writeFn,
});
const handler = logger.createStreamHandler('step-a', 0);
handler({ type: 'text', data: { text: '\x1b[41mRed background\x1b[0m\n' } } as StreamEvent);
expect(output).toHaveLength(1);
expect(output[0]).toContain('Red background');
expect(output[0]).not.toContain('\x1b[41m');
});
it('should strip ANSI codes from thinking events', () => {
const logger = new ParallelLogger({
subMovementNames: ['step-a'],
writeFn,
});
const handler = logger.createStreamHandler('step-a', 0);
handler({
type: 'thinking',
data: { thinking: '\x1b[31mColored thought\x1b[0m' },
} as StreamEvent);
expect(output).toHaveLength(1);
expect(output[0]).toContain('Colored thought');
expect(output[0]).not.toContain('\x1b[31m');
});
it('should strip ANSI codes from tool_output events', () => {
const logger = new ParallelLogger({
subMovementNames: ['step-a'],
writeFn,
});
const handler = logger.createStreamHandler('step-a', 0);
handler({
type: 'tool_output',
data: { tool: 'Bash', output: '\x1b[32mGreen output\x1b[0m' },
} as StreamEvent);
expect(output).toHaveLength(1);
expect(output[0]).toContain('Green output');
expect(output[0]).not.toContain('\x1b[32m');
});
it('should strip ANSI codes from tool_result events', () => {
const logger = new ParallelLogger({
subMovementNames: ['step-a'],
writeFn,
});
const handler = logger.createStreamHandler('step-a', 0);
handler({
type: 'tool_result',
data: { content: '\x1b[31mResult with ANSI\x1b[0m', isError: false },
} as StreamEvent);
expect(output).toHaveLength(1);
expect(output[0]).toContain('Result with ANSI');
expect(output[0]).not.toContain('\x1b[31m');
});
it('should strip ANSI codes from buffered text across multiple events', () => {
const logger = new ParallelLogger({
subMovementNames: ['step-a'],
writeFn,
});
const handler = logger.createStreamHandler('step-a', 0);
handler({ type: 'text', data: { text: '\x1b[31mHello' } } as StreamEvent);
handler({ type: 'text', data: { text: ' World\x1b[0m\n' } } as StreamEvent);
expect(output).toHaveLength(1);
expect(output[0]).toContain('Hello World');
// The prefix contains its own ANSI codes (\x1b[36m, \x1b[0m), so
// verify the AI-originated \x1b[31m was stripped, not the prefix's codes
expect(output[0]).not.toContain('\x1b[31m');
});
});
describe('interleaved output from multiple sub-movements', () => { describe('interleaved output from multiple sub-movements', () => {
it('should correctly interleave prefixed output', () => { it('should correctly interleave prefixed output', () => {
const logger = new ParallelLogger({ const logger = new ParallelLogger({

View File

@ -0,0 +1,190 @@
/**
* Integration tests: debug prompt log wiring in executePiece().
*/
import { describe, it, expect, beforeEach, vi } from 'vitest';
import type { PieceConfig } from '../core/models/index.js';
const { mockIsDebugEnabled, mockWritePromptLog, MockPieceEngine } = vi.hoisted(() => {
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { EventEmitter: EE } = require('node:events') as typeof import('node:events');
const mockIsDebugEnabled = vi.fn().mockReturnValue(true);
const mockWritePromptLog = vi.fn();
class MockPieceEngine extends EE {
private config: PieceConfig;
constructor(config: PieceConfig, _cwd: string, _task: string, _options: unknown) {
super();
this.config = config;
}
abort(): void {}
async run(): Promise<{ status: string; iteration: number }> {
const step = this.config.movements[0]!;
const timestamp = new Date('2026-02-07T00:00:00.000Z');
this.emit('movement:start', step, 1, 'movement instruction');
this.emit('phase:start', step, 1, 'execute', 'phase prompt');
this.emit('phase:complete', step, 1, 'execute', 'phase response', 'done');
this.emit(
'movement:complete',
step,
{
persona: step.personaDisplayName,
status: 'done',
content: 'movement response',
timestamp,
},
'movement instruction'
);
this.emit('piece:complete', { status: 'completed', iteration: 1 });
return { status: 'completed', iteration: 1 };
}
}
return { mockIsDebugEnabled, mockWritePromptLog, MockPieceEngine };
});
vi.mock('../core/piece/index.js', () => ({
PieceEngine: MockPieceEngine,
}));
vi.mock('../infra/claude/index.js', () => ({
callAiJudge: vi.fn(),
detectRuleIndex: vi.fn(),
interruptAllQueries: vi.fn(),
}));
vi.mock('../infra/config/index.js', () => ({
loadPersonaSessions: vi.fn().mockReturnValue({}),
updatePersonaSession: vi.fn(),
loadWorktreeSessions: vi.fn().mockReturnValue({}),
updateWorktreeSession: vi.fn(),
loadGlobalConfig: vi.fn().mockReturnValue({ provider: 'claude' }),
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,
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: vi.fn(),
notifyError: vi.fn(),
preventSleep: vi.fn(),
isDebugEnabled: mockIsDebugEnabled,
writePromptLog: mockWritePromptLog,
}));
vi.mock('../shared/prompt/index.js', () => ({
selectOption: vi.fn(),
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 { executePiece } from '../features/tasks/execute/pieceExecution.js';
describe('executePiece debug prompts logging', () => {
beforeEach(() => {
vi.clearAllMocks();
});
function makeConfig(): PieceConfig {
return {
name: 'test-piece',
maxIterations: 5,
initialMovement: 'implement',
movements: [
{
name: 'implement',
persona: '../agents/coder.md',
personaDisplayName: 'coder',
instructionTemplate: 'Implement task',
passPreviousResponse: true,
rules: [{ condition: 'done', next: 'COMPLETE' }],
},
],
};
}
it('should write prompt log record when debug is enabled', async () => {
mockIsDebugEnabled.mockReturnValue(true);
await executePiece(makeConfig(), 'task', '/tmp/project', {
projectCwd: '/tmp/project',
});
expect(mockWritePromptLog).toHaveBeenCalledTimes(1);
const record = mockWritePromptLog.mock.calls[0]?.[0] as {
movement: string;
phase: number;
iteration: number;
prompt: string;
response: string;
timestamp: string;
};
expect(record.movement).toBe('implement');
expect(record.phase).toBe(1);
expect(record.iteration).toBe(1);
expect(record.prompt).toBe('phase prompt');
expect(record.response).toBe('phase response');
expect(record.timestamp).toMatch(/^\d{4}-\d{2}-\d{2}T/);
});
it('should not write prompt log record when debug is disabled', async () => {
mockIsDebugEnabled.mockReturnValue(false);
await executePiece(makeConfig(), 'task', '/tmp/project', {
projectCwd: '/tmp/project',
});
expect(mockWritePromptLog).not.toHaveBeenCalled();
});
});

View File

@ -0,0 +1,104 @@
/**
* Tests for resolveAutoPr default behavior in selectAndExecuteTask
*/
import { describe, it, expect, vi, beforeEach } from 'vitest';
vi.mock('../shared/prompt/index.js', () => ({
confirm: vi.fn(),
}));
vi.mock('../infra/config/index.js', () => ({
getCurrentPiece: vi.fn(),
listPieces: vi.fn(() => ['default']),
listPieceEntries: vi.fn(() => []),
isPiecePath: vi.fn(() => false),
loadAllPiecesWithSources: vi.fn(() => new Map()),
getPieceCategories: vi.fn(() => null),
buildCategorizedPieces: vi.fn(),
loadGlobalConfig: vi.fn(() => ({})),
}));
vi.mock('../infra/task/index.js', () => ({
createSharedClone: vi.fn(),
autoCommitAndPush: vi.fn(),
summarizeTaskName: vi.fn(),
}));
vi.mock('../shared/ui/index.js', () => ({
info: vi.fn(),
error: vi.fn(),
success: vi.fn(),
}));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
createLogger: () => ({
info: vi.fn(),
debug: vi.fn(),
error: vi.fn(),
}),
}));
vi.mock('../infra/github/index.js', () => ({
createPullRequest: vi.fn(),
buildPrBody: vi.fn(),
pushBranch: vi.fn(),
}));
vi.mock('../features/tasks/execute/taskExecution.js', () => ({
executeTask: vi.fn(),
}));
vi.mock('../features/pieceSelection/index.js', () => ({
warnMissingPieces: vi.fn(),
selectPieceFromCategorizedPieces: vi.fn(),
selectPieceFromEntries: vi.fn(),
}));
import { confirm } from '../shared/prompt/index.js';
import { createSharedClone, autoCommitAndPush, summarizeTaskName } from '../infra/task/index.js';
import { selectAndExecuteTask } from '../features/tasks/execute/selectAndExecute.js';
const mockConfirm = vi.mocked(confirm);
const mockCreateSharedClone = vi.mocked(createSharedClone);
const mockAutoCommitAndPush = vi.mocked(autoCommitAndPush);
const mockSummarizeTaskName = vi.mocked(summarizeTaskName);
beforeEach(() => {
vi.clearAllMocks();
});
describe('resolveAutoPr default in selectAndExecuteTask', () => {
it('should call auto-PR confirm with default true when no CLI option or config', async () => {
// Given: worktree is enabled via override, no autoPr option, no global config autoPr
mockConfirm.mockResolvedValue(true);
mockSummarizeTaskName.mockResolvedValue('test-task');
mockCreateSharedClone.mockReturnValue({
path: '/project/../clone',
branch: 'takt/test-task',
});
const { executeTask } = await import(
'../features/tasks/execute/taskExecution.js'
);
vi.mocked(executeTask).mockResolvedValue(true);
mockAutoCommitAndPush.mockReturnValue({
success: false,
message: 'no changes',
});
// When
await selectAndExecuteTask('/project', 'test task', {
piece: 'default',
createWorktree: true,
});
// Then: the 'Create pull request?' confirm is called with default true
const autoPrCall = mockConfirm.mock.calls.find(
(call) => call[0] === 'Create pull request?',
);
expect(autoPrCall).toBeDefined();
expect(autoPrCall![1]).toBe(true);
});
});

View File

@ -0,0 +1,83 @@
/**
* Tests for stripAnsi utility function
*/
import { describe, it, expect } from 'vitest';
import { stripAnsi } from '../shared/utils/text.js';
describe('stripAnsi', () => {
it('should return plain text unchanged', () => {
expect(stripAnsi('Hello World')).toBe('Hello World');
});
it('should return empty string unchanged', () => {
expect(stripAnsi('')).toBe('');
});
it('should strip foreground color codes', () => {
// Red text: ESC[31m ... ESC[0m
expect(stripAnsi('\x1b[31mError\x1b[0m')).toBe('Error');
});
it('should strip background color codes', () => {
// Red background: ESC[41m ... ESC[0m
expect(stripAnsi('\x1b[41mHighlighted\x1b[0m')).toBe('Highlighted');
});
it('should strip combined foreground and background codes', () => {
// White text on red background: ESC[37;41m
expect(stripAnsi('\x1b[37;41mAlert\x1b[0m')).toBe('Alert');
});
it('should strip multiple SGR sequences in one string', () => {
const input = '\x1b[1mBold\x1b[0m normal \x1b[32mGreen\x1b[0m';
expect(stripAnsi(input)).toBe('Bold normal Green');
});
it('should strip 256-color sequences', () => {
// ESC[38;5;196m (foreground 256-color red)
expect(stripAnsi('\x1b[38;5;196mRed256\x1b[0m')).toBe('Red256');
});
it('should strip cursor movement sequences', () => {
// Cursor up: ESC[1A, Cursor right: ESC[5C
expect(stripAnsi('\x1b[1AUp\x1b[5CRight')).toBe('UpRight');
});
it('should strip erase sequences', () => {
// Clear line: ESC[2K
expect(stripAnsi('\x1b[2KCleared')).toBe('Cleared');
});
it('should strip OSC sequences terminated by BEL', () => {
// Set terminal title: ESC]0;Title BEL
expect(stripAnsi('\x1b]0;My Title\x07Text')).toBe('Text');
});
it('should strip OSC sequences terminated by ST', () => {
// Set terminal title: ESC]0;Title ESC\
expect(stripAnsi('\x1b]0;My Title\x1b\\Text')).toBe('Text');
});
it('should strip other single-character escape codes', () => {
// ESC followed by a single char (e.g., ESC M = reverse line feed)
expect(stripAnsi('\x1bMText')).toBe('Text');
});
it('should preserve newlines and whitespace', () => {
expect(stripAnsi('\x1b[31mLine1\n\x1b[32mLine2\n')).toBe('Line1\nLine2\n');
});
it('should strip sequences without a reset at the end', () => {
// Simulates the reported bug: background color set without reset
expect(stripAnsi('\x1b[41mRed background text')).toBe('Red background text');
});
it('should handle text with only ANSI sequences', () => {
expect(stripAnsi('\x1b[31m\x1b[0m')).toBe('');
});
it('should handle consecutive ANSI sequences', () => {
expect(stripAnsi('\x1b[1m\x1b[31m\x1b[42mStyled\x1b[0m')).toBe('Styled');
});
});

View File

@ -7,6 +7,7 @@
*/ */
import type { StreamCallback, StreamEvent } from '../types.js'; import type { StreamCallback, StreamEvent } from '../types.js';
import { stripAnsi } from '../../../shared/utils/text.js';
/** ANSI color codes for sub-movement prefixes (cycled in order) */ /** ANSI color codes for sub-movement prefixes (cycled in order) */
const COLORS = ['\x1b[36m', '\x1b[33m', '\x1b[35m', '\x1b[32m'] as const; // cyan, yellow, magenta, green const COLORS = ['\x1b[36m', '\x1b[33m', '\x1b[35m', '\x1b[32m'] as const; // cyan, yellow, magenta, green
@ -117,7 +118,7 @@ export class ParallelLogger {
*/ */
private handleTextEvent(name: string, prefix: string, text: string): void { private handleTextEvent(name: string, prefix: string, text: string): void {
const buffer = this.lineBuffers.get(name) ?? ''; const buffer = this.lineBuffers.get(name) ?? '';
const combined = buffer + text; const combined = buffer + stripAnsi(text);
const parts = combined.split('\n'); const parts = combined.split('\n');
// Last part is incomplete (no trailing newline) — keep in buffer // Last part is incomplete (no trailing newline) — keep in buffer
@ -145,13 +146,13 @@ export class ParallelLogger {
text = `[tool] ${event.data.tool}`; text = `[tool] ${event.data.tool}`;
break; break;
case 'tool_result': case 'tool_result':
text = event.data.content; text = stripAnsi(event.data.content);
break; break;
case 'tool_output': case 'tool_output':
text = event.data.output; text = stripAnsi(event.data.output);
break; break;
case 'thinking': case 'thinking':
text = event.data.thinking; text = stripAnsi(event.data.thinking);
break; break;
default: default:
return; return;

View File

@ -186,7 +186,7 @@ export async function addTask(cwd: string, task?: string): Promise<void> {
} }
// PR確認worktreeが有効な場合のみ // PR確認worktreeが有効な場合のみ
autoPr = await confirm('Auto-create PR?', false); autoPr = await confirm('Auto-create PR?', true);
} }
// YAMLファイル作成 // YAMLファイル作成

View File

@ -46,7 +46,15 @@ import {
type NdjsonInteractiveStart, type NdjsonInteractiveStart,
type NdjsonInteractiveEnd, type NdjsonInteractiveEnd,
} from '../../../infra/fs/index.js'; } from '../../../infra/fs/index.js';
import { createLogger, notifySuccess, notifyError, preventSleep } from '../../../shared/utils/index.js'; import {
createLogger,
notifySuccess,
notifyError,
preventSleep,
isDebugEnabled,
writePromptLog,
} from '../../../shared/utils/index.js';
import type { PromptLogRecord } from '../../../shared/utils/index.js';
import { selectOption, promptInput } from '../../../shared/prompt/index.js'; import { selectOption, promptInput } from '../../../shared/prompt/index.js';
import { EXIT_SIGINT } from '../../../shared/exitCodes.js'; import { EXIT_SIGINT } from '../../../shared/exitCodes.js';
import { getLabel } from '../../../shared/i18n/index.js'; import { getLabel } from '../../../shared/i18n/index.js';
@ -241,6 +249,8 @@ export async function executePiece(
let abortReason: string | undefined; let abortReason: string | undefined;
let lastMovementContent: string | undefined; let lastMovementContent: string | undefined;
let lastMovementName: string | undefined; let lastMovementName: string | undefined;
let currentIteration = 0;
const phasePrompts = new Map<string, string>();
engine.on('phase:start', (step, phase, phaseName, instruction) => { engine.on('phase:start', (step, phase, phaseName, instruction) => {
log.debug('Phase starting', { step: step.name, phase, phaseName }); log.debug('Phase starting', { step: step.name, phase, phaseName });
@ -253,6 +263,10 @@ export async function executePiece(
...(instruction ? { instruction } : {}), ...(instruction ? { instruction } : {}),
}; };
appendNdjsonLine(ndjsonLogPath, record); appendNdjsonLine(ndjsonLogPath, record);
if (isDebugEnabled()) {
phasePrompts.set(`${step.name}:${phase}`, instruction);
}
}); });
engine.on('phase:complete', (step, phase, phaseName, content, phaseStatus, phaseError) => { engine.on('phase:complete', (step, phase, phaseName, content, phaseStatus, phaseError) => {
@ -263,15 +277,34 @@ export async function executePiece(
phase, phase,
phaseName, phaseName,
status: phaseStatus, status: phaseStatus,
content: content ?? '', content,
timestamp: new Date().toISOString(), timestamp: new Date().toISOString(),
...(phaseError ? { error: phaseError } : {}), ...(phaseError ? { error: phaseError } : {}),
}; };
appendNdjsonLine(ndjsonLogPath, record); appendNdjsonLine(ndjsonLogPath, record);
const promptKey = `${step.name}:${phase}`;
const prompt = phasePrompts.get(promptKey);
phasePrompts.delete(promptKey);
if (isDebugEnabled()) {
if (prompt) {
const promptRecord: PromptLogRecord = {
movement: step.name,
phase,
iteration: currentIteration,
prompt,
response: content,
timestamp: new Date().toISOString(),
};
writePromptLog(promptRecord);
}
}
}); });
engine.on('movement:start', (step, iteration, instruction) => { engine.on('movement:start', (step, iteration, instruction) => {
log.debug('Movement starting', { step: step.name, persona: step.personaDisplayName, iteration }); log.debug('Movement starting', { step: step.name, persona: step.personaDisplayName, iteration });
currentIteration = iteration;
info(`[${iteration}/${pieceConfig.maxIterations}] ${step.name} (${step.personaDisplayName})`); info(`[${iteration}/${pieceConfig.maxIterations}] ${step.name} (${step.personaDisplayName})`);
// Log prompt content for debugging // Log prompt content for debugging

View File

@ -141,7 +141,7 @@ async function resolveAutoPr(optionAutoPr: boolean | undefined): Promise<boolean
} }
// Fall back to interactive prompt // Fall back to interactive prompt
return confirm('Create pull request?', false); return confirm('Create pull request?', true);
} }
/** /**

View File

@ -12,6 +12,7 @@ import chalk from 'chalk';
// dependent event-data types, which is out of scope for this refactoring. // dependent event-data types, which is out of scope for this refactoring.
import type { StreamEvent, StreamCallback } from '../../core/piece/index.js'; import type { StreamEvent, StreamCallback } from '../../core/piece/index.js';
import { truncate } from './LogManager.js'; import { truncate } from './LogManager.js';
import { stripAnsi } from '../utils/text.js';
/** Progress information for stream display */ /** Progress information for stream display */
export interface ProgressInfo { export interface ProgressInfo {
@ -116,7 +117,7 @@ export class StreamDisplay {
this.lastToolUse = tool; this.lastToolUse = tool;
} }
this.toolOutputBuffer += output; this.toolOutputBuffer += stripAnsi(output);
const lines = this.toolOutputBuffer.split(/\r?\n/); const lines = this.toolOutputBuffer.split(/\r?\n/);
this.toolOutputBuffer = lines.pop() ?? ''; this.toolOutputBuffer = lines.pop() ?? '';
@ -129,11 +130,12 @@ export class StreamDisplay {
showToolResult(content: string, isError: boolean): void { showToolResult(content: string, isError: boolean): void {
this.stopToolSpinner(); this.stopToolSpinner();
const sanitizedContent = stripAnsi(content);
if (this.quiet) { if (this.quiet) {
if (isError) { if (isError) {
const toolName = this.lastToolUse || 'Tool'; const toolName = this.lastToolUse || 'Tool';
const errorContent = content || 'Unknown error'; const errorContent = sanitizedContent || 'Unknown error';
console.log(chalk.red(`${toolName}:`), chalk.red(truncate(errorContent, 70))); console.log(chalk.red(`${toolName}:`), chalk.red(truncate(errorContent, 70)));
} }
this.lastToolUse = null; this.lastToolUse = null;
@ -149,10 +151,10 @@ export class StreamDisplay {
const toolName = this.lastToolUse || 'Tool'; const toolName = this.lastToolUse || 'Tool';
if (isError) { if (isError) {
const errorContent = content || 'Unknown error'; const errorContent = sanitizedContent || 'Unknown error';
console.log(chalk.red(`${toolName}:`), chalk.red(truncate(errorContent, 70))); console.log(chalk.red(`${toolName}:`), chalk.red(truncate(errorContent, 70)));
} else if (content && content.length > 0) { } else if (sanitizedContent && sanitizedContent.length > 0) {
const preview = content.split('\n')[0] || content; const preview = sanitizedContent.split('\n')[0] || sanitizedContent;
console.log(chalk.green(`${toolName}`), chalk.gray(truncate(preview, 60))); console.log(chalk.green(`${toolName}`), chalk.gray(truncate(preview, 60)));
} else { } else {
console.log(chalk.green(`${toolName}`)); console.log(chalk.green(`${toolName}`));
@ -174,8 +176,9 @@ export class StreamDisplay {
console.log(chalk.magenta(`💭 [${this.agentName}]${progressPart} thinking:`)); console.log(chalk.magenta(`💭 [${this.agentName}]${progressPart} thinking:`));
this.isFirstThinking = false; this.isFirstThinking = false;
} }
process.stdout.write(chalk.gray.italic(thinking)); const sanitized = stripAnsi(thinking);
this.thinkingBuffer += thinking; process.stdout.write(chalk.gray.italic(sanitized));
this.thinkingBuffer += sanitized;
} }
flushThinking(): void { flushThinking(): void {
@ -200,8 +203,9 @@ export class StreamDisplay {
console.log(chalk.cyan(`[${this.agentName}]${progressPart}:`)); console.log(chalk.cyan(`[${this.agentName}]${progressPart}:`));
this.isFirstText = false; this.isFirstText = false;
} }
process.stdout.write(text); const sanitized = stripAnsi(text);
this.textBuffer += text; process.stdout.write(sanitized);
this.textBuffer += sanitized;
} }
flushText(): void { flushText(): void {

View File

@ -6,6 +6,7 @@
import { existsSync, appendFileSync, mkdirSync, writeFileSync } from 'node:fs'; import { existsSync, appendFileSync, mkdirSync, writeFileSync } from 'node:fs';
import { dirname, join } from 'node:path'; import { dirname, join } from 'node:path';
import type { PromptLogRecord } from './types.js';
/** Debug configuration (duplicated from core/models to avoid shared → core dependency) */ /** Debug configuration (duplicated from core/models to avoid shared → core dependency) */
interface DebugConfig { interface DebugConfig {
enabled: boolean; enabled: boolean;
@ -21,6 +22,7 @@ export class DebugLogger {
private debugEnabled = false; private debugEnabled = false;
private debugLogFile: string | null = null; private debugLogFile: string | null = null;
private debugPromptsLogFile: string | null = null;
private initialized = false; private initialized = false;
private verboseConsoleEnabled = false; private verboseConsoleEnabled = false;
@ -38,10 +40,10 @@ export class DebugLogger {
DebugLogger.instance = null; DebugLogger.instance = null;
} }
/** Get default debug log file path */ /** Get default debug log file prefix */
private static getDefaultLogFile(projectDir: string): string { private static getDefaultLogPrefix(projectDir: string): string {
const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19); const timestamp = new Date().toISOString().replace(/[:.]/g, '-').slice(0, 19);
return join(projectDir, '.takt', 'logs', `debug-${timestamp}.log`); return join(projectDir, '.takt', 'logs', `debug-${timestamp}`);
} }
/** Initialize debug logger from config */ /** Initialize debug logger from config */
@ -55,8 +57,15 @@ export class DebugLogger {
if (this.debugEnabled) { if (this.debugEnabled) {
if (config?.logFile) { if (config?.logFile) {
this.debugLogFile = config.logFile; this.debugLogFile = config.logFile;
if (config.logFile.endsWith('.log')) {
this.debugPromptsLogFile = config.logFile.slice(0, -4) + '-prompts.jsonl';
} else {
this.debugPromptsLogFile = `${config.logFile}-prompts.jsonl`;
}
} else if (projectDir) { } else if (projectDir) {
this.debugLogFile = DebugLogger.getDefaultLogFile(projectDir); const logPrefix = DebugLogger.getDefaultLogPrefix(projectDir);
this.debugLogFile = `${logPrefix}.log`;
this.debugPromptsLogFile = `${logPrefix}-prompts.jsonl`;
} }
if (this.debugLogFile) { if (this.debugLogFile) {
@ -76,6 +85,14 @@ export class DebugLogger {
writeFileSync(this.debugLogFile, header, 'utf-8'); writeFileSync(this.debugLogFile, header, 'utf-8');
} }
if (this.debugPromptsLogFile) {
const promptsLogDir = dirname(this.debugPromptsLogFile);
if (!existsSync(promptsLogDir)) {
mkdirSync(promptsLogDir, { recursive: true });
}
writeFileSync(this.debugPromptsLogFile, '', 'utf-8');
}
} }
this.initialized = true; this.initialized = true;
@ -85,6 +102,7 @@ export class DebugLogger {
reset(): void { reset(): void {
this.debugEnabled = false; this.debugEnabled = false;
this.debugLogFile = null; this.debugLogFile = null;
this.debugPromptsLogFile = null;
this.initialized = false; this.initialized = false;
this.verboseConsoleEnabled = false; this.verboseConsoleEnabled = false;
} }
@ -153,6 +171,19 @@ export class DebugLogger {
} }
} }
/** Write a prompt/response debug log entry */
writePromptLog(record: PromptLogRecord): void {
if (!this.debugEnabled || !this.debugPromptsLogFile) {
return;
}
try {
appendFileSync(this.debugPromptsLogFile, JSON.stringify(record) + '\n', 'utf-8');
} catch {
// Silently fail - logging errors should not interrupt main flow
}
}
/** Create a scoped logger for a component */ /** Create a scoped logger for a component */
createLogger(component: string) { createLogger(component: string) {
return { return {
@ -203,6 +234,10 @@ export function errorLog(component: string, message: string, data?: unknown): vo
DebugLogger.getInstance().writeLog('ERROR', component, message, data); DebugLogger.getInstance().writeLog('ERROR', component, message, data);
} }
export function writePromptLog(record: PromptLogRecord): void {
DebugLogger.getInstance().writePromptLog(record);
}
export function traceEnter(component: string, funcName: string, args?: Record<string, unknown>): void { export function traceEnter(component: string, funcName: string, args?: Record<string, unknown>): void {
debugLog(component, `>> ${funcName}()`, args); debugLog(component, `>> ${funcName}()`, args);
} }

View File

@ -35,6 +35,21 @@ export function getDisplayWidth(text: string): number {
return width; return width;
} }
// CSI (Control Sequence Introducer): ESC [ ... final_byte
// OSC (Operating System Command): ESC ] ... (ST | BEL)
// Other escape: ESC followed by a single character
// eslint-disable-next-line no-control-regex
const ANSI_PATTERN = /\x1b\[[0-9;]*[A-Za-z]|\x1b\][^\x07\x1b]*(?:\x07|\x1b\\)|\x1b[^[\]]/g;
/**
* Strip all ANSI escape sequences from a string.
* Removes CSI sequences (colors, cursor movement, etc.),
* OSC sequences, and other single-character escape codes.
*/
export function stripAnsi(text: string): string {
return text.replace(ANSI_PATTERN, '');
}
/** /**
* Truncate plain text to fit within maxWidth display columns. * Truncate plain text to fit within maxWidth display columns.
* Appends '…' if truncated. The ellipsis itself counts as 1 column. * Appends '…' if truncated. The ellipsis itself counts as 1 column.

View File

@ -129,3 +129,13 @@ export interface LatestLogPointer {
updatedAt: string; updatedAt: string;
iterations: number; iterations: number;
} }
/** Record for debug prompt/response log (debug-*-prompts.jsonl) */
export interface PromptLogRecord {
movement: string;
phase: 1 | 2 | 3;
iteration: number;
prompt: string;
response: string;
timestamp: string;
}