feat: インタラクティブモードのスラッシュコマンドを行末でも認識可能にする (#406)

- スラッシュコマンド検出ロジックを commandMatcher.ts に分離
- 行頭・行末の両方でコマンドを認識し、行中は無視する仕様を実装
- conversationLoop を早期リターン + switch ディスパッチにリファクタリング
- SlashCommand 定数を shared/constants に追加
- コマンドマッチングのユニットテスト36件を追加
- 行末コマンド・行中非認識のE2Eテスト6件を追加

Co-authored-by: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
Yuma Satake 2026-02-28 12:59:26 +09:00 committed by GitHub
parent 09fda82677
commit e77cb50ac1
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
6 changed files with 409 additions and 93 deletions

View File

@ -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' });
});
});

View File

@ -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 // Session management: new sessionId propagates across calls
// ================================================================= // =================================================================

View File

@ -142,7 +142,8 @@ vi.mock('../shared/context.js', () => ({
isQuietMode: vi.fn(() => false), isQuietMode: vi.fn(() => false),
})); }));
vi.mock('../shared/constants.js', () => ({ vi.mock('../shared/constants.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
DEFAULT_PIECE_NAME: 'default', DEFAULT_PIECE_NAME: 'default',
DEFAULT_LANGUAGE: 'en', DEFAULT_LANGUAGE: 'en',
})); }));

View File

@ -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;
};

View File

@ -19,6 +19,8 @@ import { info, error, blankLine } from '../../shared/ui/index.js';
import { getLabel, getLabelObject } from '../../shared/i18n/index.js'; import { getLabel, getLabelObject } from '../../shared/i18n/index.js';
import { readMultilineInput } from './lineEditor.js'; import { readMultilineInput } from './lineEditor.js';
import { selectRecentSession } from './sessionSelector.js'; import { selectRecentSession } from './sessionSelector.js';
import { matchSlashCommand } from './commandMatcher.js';
import { SlashCommand } from '../../shared/constants.js';
import { import {
type PieceContext, type PieceContext,
type InteractiveModeResult, type InteractiveModeResult,
@ -157,17 +159,42 @@ export async function runConversationLoop(
continue; continue;
} }
if (trimmed.startsWith('/play')) { const match = matchSlashCommand(trimmed);
const task = trimmed.slice(5).trim();
if (!task) { // 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();
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();
} else {
history.pop();
}
continue;
}
switch (match.command) {
case SlashCommand.Play: {
if (!match.text) {
info(ui.playNoTask); info(ui.playNoTask);
continue; continue;
} }
log.info('Play command', { task }); log.info('Play command', { task: match.text });
return { action: 'execute', task }; return { action: 'execute', task: match.text };
} }
if (trimmed === '/retry') { case SlashCommand.Retry: {
if (!strategy.enableRetryCommand) { if (!strategy.enableRetryCommand) {
info(ui.retryUnavailable); info(ui.retryUnavailable);
continue; continue;
@ -180,8 +207,8 @@ export async function runConversationLoop(
return { action: 'execute', task: strategy.previousOrderContent }; return { action: 'execute', task: strategy.previousOrderContent };
} }
if (trimmed.startsWith('/go')) { case SlashCommand.Go: {
const userNote = trimmed.slice(3).trim(); const userNote = match.text;
let summaryPrompt = buildSummaryPrompt( let summaryPrompt = buildSummaryPrompt(
history, !!sessionId, ctx.lang, noTranscript, conversationLabel, pieceContext, history, !!sessionId, ctx.lang, noTranscript, conversationLabel, pieceContext,
); );
@ -218,7 +245,7 @@ export async function runConversationLoop(
return { action: selectedAction, task }; return { action: selectedAction, task };
} }
if (trimmed === '/replay') { case SlashCommand.Replay: {
if (!strategy.previousOrderContent) { if (!strategy.previousOrderContent) {
const replayNoOrder = getLabel('instruct.ui.replayNoOrder', ctx.lang); const replayNoOrder = getLabel('instruct.ui.replayNoOrder', ctx.lang);
info(replayNoOrder); info(replayNoOrder);
@ -228,12 +255,12 @@ export async function runConversationLoop(
return { action: 'execute', task: strategy.previousOrderContent }; return { action: 'execute', task: strategy.previousOrderContent };
} }
if (trimmed === '/cancel') { case SlashCommand.Cancel: {
info(ui.cancelled); info(ui.cancelled);
return { action: 'cancel', task: '' }; return { action: 'cancel', task: '' };
} }
if (trimmed === '/resume') { case SlashCommand.Resume: {
const selectedId = await selectRecentSession(cwd, ctx.lang); const selectedId = await selectRecentSession(cwd, ctx.lang);
if (selectedId) { if (selectedId) {
sessionId = selectedId; sessionId = selectedId;
@ -241,24 +268,6 @@ export async function runConversationLoop(
} }
continue; continue;
} }
history.push({ role: 'user', content: trimmed });
log.debug('Sending to AI', { messageCount: history.length, sessionId });
process.stdin.pause();
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();
} else {
history.pop();
} }
} }
} }

View File

@ -10,3 +10,14 @@ export const DEFAULT_PIECE_NAME = 'default';
/** Default language for new installations */ /** Default language for new installations */
export const DEFAULT_LANGUAGE: Language = 'en'; 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];