From e77cb50ac14addf553a621d92eb2219e58b4699a Mon Sep 17 00:00:00 2001 From: Yuma Satake Date: Sat, 28 Feb 2026 12:59:26 +0900 Subject: [PATCH] =?UTF-8?q?feat:=20=E3=82=A4=E3=83=B3=E3=82=BF=E3=83=A9?= =?UTF-8?q?=E3=82=AF=E3=83=86=E3=82=A3=E3=83=96=E3=83=A2=E3=83=BC=E3=83=89?= =?UTF-8?q?=E3=81=AE=E3=82=B9=E3=83=A9=E3=83=83=E3=82=B7=E3=83=A5=E3=82=B3?= =?UTF-8?q?=E3=83=9E=E3=83=B3=E3=83=89=E3=82=92=E8=A1=8C=E6=9C=AB=E3=81=A7?= =?UTF-8?q?=E3=82=82=E8=AA=8D=E8=AD=98=E5=8F=AF=E8=83=BD=E3=81=AB=E3=81=99?= =?UTF-8?q?=E3=82=8B=20(#406)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - スラッシュコマンド検出ロジックを commandMatcher.ts に分離 - 行頭・行末の両方でコマンドを認識し、行中は無視する仕様を実装 - conversationLoop を早期リターン + switch ディスパッチにリファクタリング - SlashCommand 定数を shared/constants に追加 - コマンドマッチングのユニットテスト36件を追加 - 行末コマンド・行中非認識のE2Eテスト6件を追加 Co-authored-by: Claude Opus 4.6 --- src/__tests__/commandMatcher.test.ts | 198 ++++++++++++++++++ src/__tests__/it-interactive-routes.test.ts | 61 ++++++ src/__tests__/runAllTasks-concurrency.test.ts | 3 +- src/features/interactive/commandMatcher.ts | 36 ++++ src/features/interactive/conversationLoop.ts | 193 +++++++++-------- src/shared/constants.ts | 11 + 6 files changed, 409 insertions(+), 93 deletions(-) create mode 100644 src/__tests__/commandMatcher.test.ts create mode 100644 src/features/interactive/commandMatcher.ts diff --git a/src/__tests__/commandMatcher.test.ts b/src/__tests__/commandMatcher.test.ts new file mode 100644 index 0000000..64bd356 --- /dev/null +++ b/src/__tests__/commandMatcher.test.ts @@ -0,0 +1,198 @@ +/** + * Unit tests for the slash command parser. + * + * Verifies command detection at the beginning and end of input, + * and ensures commands in the middle of text are not recognized. + */ + +import { describe, it, expect } from 'vitest'; +import { matchSlashCommand } from '../features/interactive/commandMatcher.js'; + +// ================================================================= +// Start-of-line detection (existing behavior) +// ================================================================= +describe('start-of-line detection', () => { + it('should detect /play with task text', () => { + const result = matchSlashCommand('/play fix the login bug'); + expect(result).toEqual({ command: '/play', text: 'fix the login bug' }); + }); + + it('should detect /play without task text', () => { + const result = matchSlashCommand('/play'); + expect(result).toEqual({ command: '/play', text: '' }); + }); + + it('should detect /go without note', () => { + const result = matchSlashCommand('/go'); + expect(result).toEqual({ command: '/go', text: '' }); + }); + + it('should detect /go with user note', () => { + const result = matchSlashCommand('/go also check security'); + expect(result).toEqual({ command: '/go', text: 'also check security' }); + }); + + it('should detect /cancel', () => { + const result = matchSlashCommand('/cancel'); + expect(result).toEqual({ command: '/cancel', text: '' }); + }); + + it('should detect /retry', () => { + const result = matchSlashCommand('/retry'); + expect(result).toEqual({ command: '/retry', text: '' }); + }); + + it('should detect /replay', () => { + const result = matchSlashCommand('/replay'); + expect(result).toEqual({ command: '/replay', text: '' }); + }); + + it('should detect /resume', () => { + const result = matchSlashCommand('/resume'); + expect(result).toEqual({ command: '/resume', text: '' }); + }); +}); + +// ================================================================= +// End-of-line detection (new behavior) +// ================================================================= +describe('end-of-line detection', () => { + it('should detect /play at the end with preceding text as task', () => { + const result = matchSlashCommand('fix the login bug /play'); + expect(result).toEqual({ command: '/play', text: 'fix the login bug' }); + }); + + it('should detect /go at the end with preceding text as user note', () => { + const result = matchSlashCommand('ここまでの内容で実行して /go'); + expect(result).toEqual({ command: '/go', text: 'ここまでの内容で実行して' }); + }); + + it('should detect /go at the end without preceding text', () => { + const result = matchSlashCommand('some text /go'); + expect(result).toEqual({ command: '/go', text: 'some text' }); + }); + + it('should detect /cancel at the end', () => { + const result = matchSlashCommand('やっぱりやめる /cancel'); + expect(result).toEqual({ command: '/cancel', text: 'やっぱりやめる' }); + }); + + it('should detect /retry at the end', () => { + const result = matchSlashCommand('もう一回 /retry'); + expect(result).toEqual({ command: '/retry', text: 'もう一回' }); + }); + + it('should detect /replay at the end', () => { + const result = matchSlashCommand('再実行して /replay'); + expect(result).toEqual({ command: '/replay', text: '再実行して' }); + }); + + it('should detect /resume at the end', () => { + const result = matchSlashCommand('セッション復元 /resume'); + expect(result).toEqual({ command: '/resume', text: 'セッション復元' }); + }); +}); + +// ================================================================= +// Middle-of-text: NOT recognized +// ================================================================= +describe('middle-of-text (not recognized)', () => { + it('should not detect /go in the middle of text', () => { + const result = matchSlashCommand('テキスト中に /go を含むがコマンドではない文'); + expect(result).toBeNull(); + }); + + it('should not detect /play in the middle of text', () => { + const result = matchSlashCommand('I want to /play around with the code later'); + expect(result).toBeNull(); + }); + + it('should not detect /cancel in the middle of text', () => { + const result = matchSlashCommand('we should /cancel the order and redo'); + expect(result).toBeNull(); + }); + + it('should not detect /retry in the middle of text', () => { + const result = matchSlashCommand('lets /retry that approach first'); + expect(result).toBeNull(); + }); + + it('should not detect /replay in the middle of text', () => { + expect(matchSlashCommand('please /replay the scenario')).toBeNull(); + }); + + it('should not detect /resume in the middle of text', () => { + expect(matchSlashCommand('I want to /resume the work now')).toBeNull(); + }); +}); + +// ================================================================= +// Edge cases +// ================================================================= +describe('edge cases', () => { + it('should return null for empty input', () => { + expect(matchSlashCommand('')).toBeNull(); + }); + + it('should return null for regular text without commands', () => { + expect(matchSlashCommand('hello world')).toBeNull(); + }); + + it('should not match command without space separator at end', () => { + expect(matchSlashCommand('text/go')).toBeNull(); + }); + + it('should not match unknown slash command', () => { + expect(matchSlashCommand('/unknown')).toBeNull(); + }); + + it('should not match unknown slash command at end', () => { + expect(matchSlashCommand('text /unknown')).toBeNull(); + }); + + it('should prioritize start-of-line over end-of-line', () => { + const result = matchSlashCommand('/go /cancel'); + expect(result).toEqual({ command: '/go', text: '/cancel' }); + }); + + it('should handle multiple spaces between text and end command', () => { + const result = matchSlashCommand('text /go'); + expect(result).toEqual({ command: '/go', text: 'text' }); + }); + + it('should handle /play with extra spaces in task', () => { + const result = matchSlashCommand('/play fix the bug'); + expect(result).toEqual({ command: '/play', text: 'fix the bug' }); + }); + + it('should not match /go followed by characters without space', () => { + expect(matchSlashCommand('/goextra')).toBeNull(); + }); + + it('should not match /play as prefix of another word', () => { + expect(matchSlashCommand('/playing around')).toBeNull(); + }); + + it('should not match partial command at end of input', () => { + expect(matchSlashCommand('text /gopher')).toBeNull(); + }); + + it('should not match case-insensitive commands', () => { + expect(matchSlashCommand('/Go')).toBeNull(); + expect(matchSlashCommand('/PLAY')).toBeNull(); + expect(matchSlashCommand('/Cancel')).toBeNull(); + }); + + it('should not match slash only', () => { + expect(matchSlashCommand('/')).toBeNull(); + }); + + it('should not match slash with space before command name', () => { + expect(matchSlashCommand('/ go')).toBeNull(); + }); + + it('should match last command when multiple commands at end', () => { + const result = matchSlashCommand('text /go /cancel'); + expect(result).toEqual({ command: '/cancel', text: 'text /go' }); + }); +}); diff --git a/src/__tests__/it-interactive-routes.test.ts b/src/__tests__/it-interactive-routes.test.ts index a7745d6..940d2e0 100644 --- a/src/__tests__/it-interactive-routes.test.ts +++ b/src/__tests__/it-interactive-routes.test.ts @@ -365,6 +365,67 @@ describe('/play empty task error', () => { }); }); +// ================================================================= +// Route H: End-of-line slash commands +// ================================================================= +describe('end-of-line /play command', () => { + it('should return execute with preceding text as task', async () => { + setupRawStdin(toRawInputs(['fix the login bug /play'])); + setupProvider([]); + + const result = await runInstruct(); + + expect(result.action).toBe('execute'); + expect(result.task).toBe('fix the login bug'); + }); +}); + +describe('end-of-line /go command', () => { + it('should use preceding text as user note in summary', async () => { + setupRawStdin(toRawInputs(['refactor auth', 'also check security /go'])); + const capture = setupProvider(['Will do.', 'Refactor auth and check security.']); + + const result = await runInstruct(); + + expect(result.action).toBe('execute'); + expect(result.task).toBe('Refactor auth and check security.'); + expect(capture.prompts[1]).toContain('also check security'); + }); + + it('should reject end-of-line /go without prior conversation', async () => { + setupRawStdin(toRawInputs(['実行して /go', '/cancel'])); + setupProvider([]); + + const result = await runInstruct(); + + expect(result.action).toBe('cancel'); + }); +}); + +describe('end-of-line /cancel command', () => { + it('should cancel when /cancel is at the end of input', async () => { + setupRawStdin(toRawInputs(['やっぱりやめる /cancel'])); + setupProvider([]); + + const result = await runInstruct(); + + expect(result.action).toBe('cancel'); + expect(result.task).toBe(''); + }); +}); + +describe('middle-of-text command is not recognized', () => { + it('should treat text with /go in the middle as a regular message', async () => { + setupRawStdin(toRawInputs(['テキスト中に /go を含むがコマンドではない文', '/cancel'])); + const capture = setupProvider(['OK.']); + + const result = await runInstruct(); + + expect(result.action).toBe('cancel'); + expect(capture.callCount).toBe(1); + }); +}); + // ================================================================= // Session management: new sessionId propagates across calls // ================================================================= diff --git a/src/__tests__/runAllTasks-concurrency.test.ts b/src/__tests__/runAllTasks-concurrency.test.ts index 9f9b364..347ce97 100644 --- a/src/__tests__/runAllTasks-concurrency.test.ts +++ b/src/__tests__/runAllTasks-concurrency.test.ts @@ -142,7 +142,8 @@ vi.mock('../shared/context.js', () => ({ isQuietMode: vi.fn(() => false), })); -vi.mock('../shared/constants.js', () => ({ +vi.mock('../shared/constants.js', async (importOriginal) => ({ + ...(await importOriginal>()), DEFAULT_PIECE_NAME: 'default', DEFAULT_LANGUAGE: 'en', })); diff --git a/src/features/interactive/commandMatcher.ts b/src/features/interactive/commandMatcher.ts new file mode 100644 index 0000000..377634f --- /dev/null +++ b/src/features/interactive/commandMatcher.ts @@ -0,0 +1,36 @@ +import { SlashCommand } from '../../shared/constants.js'; + +const SLASH_COMMAND_VALUES = Object.values(SlashCommand); + +/** + * Slash command parser for interactive mode. + * + * Detects slash commands at the beginning or end of user input. + * Commands in the middle of text are not recognized. + * + * @param input - User input string. + * @returns Parsed command and associated text, or null if no command found. + */ +export const matchSlashCommand = (input: string): {command: SlashCommand, text: string} | null => { + if (!input) return null; + + const prefixMatch = SLASH_COMMAND_VALUES.find((cmd) => { + if (!input.startsWith(cmd)) return false; + const rest = input.slice(cmd.length); + return rest === '' || rest.startsWith(' '); + }); + if (prefixMatch) { + const rest = input.slice(prefixMatch.length); + return { command: prefixMatch, text: rest.trim() }; + } + + const suffixMatch = SLASH_COMMAND_VALUES.find((cmd) => + input.endsWith(` ${cmd}`), + ); + if (suffixMatch) { + const precedingText = input.slice(0, -(suffixMatch.length + 1)).trim(); + return { command: suffixMatch, text: precedingText }; + } + + return null; +}; diff --git a/src/features/interactive/conversationLoop.ts b/src/features/interactive/conversationLoop.ts index 2a1fa2f..e113278 100644 --- a/src/features/interactive/conversationLoop.ts +++ b/src/features/interactive/conversationLoop.ts @@ -19,6 +19,8 @@ import { info, error, blankLine } from '../../shared/ui/index.js'; import { getLabel, getLabelObject } from '../../shared/i18n/index.js'; import { readMultilineInput } from './lineEditor.js'; import { selectRecentSession } from './sessionSelector.js'; +import { matchSlashCommand } from './commandMatcher.js'; +import { SlashCommand } from '../../shared/constants.js'; import { type PieceContext, type InteractiveModeResult, @@ -157,108 +159,115 @@ export async function runConversationLoop( continue; } - if (trimmed.startsWith('/play')) { - const task = trimmed.slice(5).trim(); - if (!task) { - info(ui.playNoTask); - continue; - } - log.info('Play command', { task }); - return { action: 'execute', task }; - } + const match = matchSlashCommand(trimmed); - if (trimmed === '/retry') { - if (!strategy.enableRetryCommand) { - info(ui.retryUnavailable); - continue; - } - if (!strategy.previousOrderContent) { - info(ui.retryNoOrder); - continue; - } - log.info('Retry command — resubmitting previous order.md'); - return { action: 'execute', task: strategy.previousOrderContent }; - } + // No slash command detected, treat as regular message + if (!match) { + history.push({ role: 'user', content: trimmed }); + log.debug('Sending to AI', { messageCount: history.length, sessionId }); + process.stdin.pause(); - if (trimmed.startsWith('/go')) { - const userNote = trimmed.slice(3).trim(); - let summaryPrompt = buildSummaryPrompt( - history, !!sessionId, ctx.lang, noTranscript, conversationLabel, pieceContext, - ); - if (!summaryPrompt) { - info(ui.noConversation); - continue; - } - if (userNote) { - summaryPrompt = `${summaryPrompt}\n\nUser Note:\n${userNote}`; - } - // Summary AI must not inherit the conversation session to avoid chat-mode behavior. - const { result: summaryResult } = await callAIWithRetry( - summaryPrompt, summaryPrompt, strategy.allowedTools, cwd, - { ...ctx, sessionId: undefined }, - ); - if (!summaryResult) { - info(ui.summarizeFailed); - continue; - } - if (!summaryResult.success) { - error(summaryResult.content); + const promptWithTransform = strategy.transformPrompt(trimmed); + const result = await doCallAI(promptWithTransform, strategy.systemPrompt, strategy.allowedTools); + if (result) { + if (!result.success) { + error(result.content); + blankLine(); + history.pop(); + return { action: 'cancel', task: '' }; + } + history.push({ role: 'assistant', content: result.content }); blankLine(); - return { action: 'cancel', task: '' }; - } - const task = summaryResult.content.trim(); - const selectedAction = strategy.selectAction - ? await strategy.selectAction(task, ctx.lang) - : await selectPostSummaryAction(task, ui.proposed, ui); - if (selectedAction === 'continue' || selectedAction === null) { - info(ui.continuePrompt); - continue; - } - log.info('Conversation action selected', { action: selectedAction, messageCount: history.length }); - return { action: selectedAction, task }; - } - - if (trimmed === '/replay') { - if (!strategy.previousOrderContent) { - const replayNoOrder = getLabel('instruct.ui.replayNoOrder', ctx.lang); - info(replayNoOrder); - continue; - } - log.info('Replay command'); - return { action: 'execute', task: strategy.previousOrderContent }; - } - - if (trimmed === '/cancel') { - info(ui.cancelled); - return { action: 'cancel', task: '' }; - } - - if (trimmed === '/resume') { - const selectedId = await selectRecentSession(cwd, ctx.lang); - if (selectedId) { - sessionId = selectedId; - info(getLabel('interactive.resumeSessionLoaded', ctx.lang)); + } else { + history.pop(); } continue; } - history.push({ role: 'user', content: trimmed }); - log.debug('Sending to AI', { messageCount: history.length, sessionId }); - process.stdin.pause(); + switch (match.command) { + case SlashCommand.Play: { + if (!match.text) { + info(ui.playNoTask); + continue; + } + log.info('Play command', { task: match.text }); + return { action: 'execute', task: match.text }; + } - const promptWithTransform = strategy.transformPrompt(trimmed); - const result = await doCallAI(promptWithTransform, strategy.systemPrompt, strategy.allowedTools); - if (result) { - if (!result.success) { - error(result.content); - blankLine(); - history.pop(); + case SlashCommand.Retry: { + if (!strategy.enableRetryCommand) { + info(ui.retryUnavailable); + continue; + } + if (!strategy.previousOrderContent) { + info(ui.retryNoOrder); + continue; + } + log.info('Retry command — resubmitting previous order.md'); + return { action: 'execute', task: strategy.previousOrderContent }; + } + + case SlashCommand.Go: { + const userNote = match.text; + let summaryPrompt = buildSummaryPrompt( + history, !!sessionId, ctx.lang, noTranscript, conversationLabel, pieceContext, + ); + if (!summaryPrompt) { + info(ui.noConversation); + continue; + } + if (userNote) { + summaryPrompt = `${summaryPrompt}\n\nUser Note:\n${userNote}`; + } + // Summary AI must not inherit the conversation session to avoid chat-mode behavior. + const { result: summaryResult } = await callAIWithRetry( + summaryPrompt, summaryPrompt, strategy.allowedTools, cwd, + { ...ctx, sessionId: undefined }, + ); + if (!summaryResult) { + info(ui.summarizeFailed); + continue; + } + if (!summaryResult.success) { + error(summaryResult.content); + blankLine(); + return { action: 'cancel', task: '' }; + } + const task = summaryResult.content.trim(); + const selectedAction = strategy.selectAction + ? await strategy.selectAction(task, ctx.lang) + : await selectPostSummaryAction(task, ui.proposed, ui); + if (selectedAction === 'continue' || selectedAction === null) { + info(ui.continuePrompt); + continue; + } + log.info('Conversation action selected', { action: selectedAction, messageCount: history.length }); + return { action: selectedAction, task }; + } + + case SlashCommand.Replay: { + if (!strategy.previousOrderContent) { + const replayNoOrder = getLabel('instruct.ui.replayNoOrder', ctx.lang); + info(replayNoOrder); + continue; + } + log.info('Replay command'); + return { action: 'execute', task: strategy.previousOrderContent }; + } + + case SlashCommand.Cancel: { + info(ui.cancelled); return { action: 'cancel', task: '' }; } - history.push({ role: 'assistant', content: result.content }); - blankLine(); - } else { - history.pop(); + + case SlashCommand.Resume: { + const selectedId = await selectRecentSession(cwd, ctx.lang); + if (selectedId) { + sessionId = selectedId; + info(getLabel('interactive.resumeSessionLoaded', ctx.lang)); + } + continue; + } } } } diff --git a/src/shared/constants.ts b/src/shared/constants.ts index 0a3c07f..4b592bd 100644 --- a/src/shared/constants.ts +++ b/src/shared/constants.ts @@ -10,3 +10,14 @@ export const DEFAULT_PIECE_NAME = 'default'; /** Default language for new installations */ export const DEFAULT_LANGUAGE: Language = 'en'; + +/** Slash commands recognized in interactive mode */ +export const SlashCommand = { + Play: '/play', + Go: '/go', + Retry: '/retry', + Replay: '/replay', + Cancel: '/cancel', + Resume: '/resume', +} as const; +export type SlashCommand = typeof SlashCommand[keyof typeof SlashCommand];