takt: add-piece-reuse-confirm (#468)

This commit is contained in:
nrs 2026-03-04 23:07:47 +09:00 committed by GitHub
parent 8403a7c892
commit 3649ce40f9
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
9 changed files with 345 additions and 84 deletions

View File

@ -139,4 +139,15 @@ describe('label integrity', () => {
expect(() => getLabelObject(key, 'ja')).not.toThrow(); expect(() => getLabelObject(key, 'ja')).not.toThrow();
} }
}); });
it('keeps only confirm-based retry piece reuse labels', () => {
expect(() => getLabel('retry.usePreviousPieceConfirm', 'en', { piece: 'default' })).not.toThrow();
expect(() => getLabel('retry.usePreviousPieceConfirm', 'ja', { piece: 'default' })).not.toThrow();
expect(() => getLabel('retry.workflowPrompt', 'en')).toThrow('Label key not found');
expect(() => getLabel('retry.usePreviousWorkflow', 'en')).toThrow('Label key not found');
expect(() => getLabel('retry.changeWorkflow', 'en')).toThrow('Label key not found');
expect(() => getLabel('retry.workflowPrompt', 'ja')).toThrow('Label key not found');
expect(() => getLabel('retry.usePreviousWorkflow', 'ja')).toThrow('Label key not found');
expect(() => getLabel('retry.changeWorkflow', 'ja')).toThrow('Label key not found');
});
}); });

View File

@ -1,7 +1,19 @@
import { describe, expect, it, vi, beforeEach } from 'vitest'; import { describe, expect, it, vi, beforeEach } from 'vitest';
const { mockDebug } = vi.hoisted(() => ({ const {
mockDebug,
mockConfirm,
mockGetLabel,
mockSelectPiece,
mockIsPiecePath,
mockLoadAllPiecesWithSources,
} = vi.hoisted(() => ({
mockDebug: vi.fn(), mockDebug: vi.fn(),
mockConfirm: vi.fn(),
mockGetLabel: vi.fn((_key: string, _lang?: string, vars?: Record<string, string>) => `Use previous piece "${vars?.piece ?? ''}"?`),
mockSelectPiece: vi.fn(),
mockIsPiecePath: vi.fn(() => false),
mockLoadAllPiecesWithSources: vi.fn(() => new Map<string, unknown>([['default', {}], ['selected-piece', {}]])),
})); }));
vi.mock('../shared/utils/index.js', async (importOriginal) => ({ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
@ -15,7 +27,25 @@ vi.mock('../shared/utils/index.js', async (importOriginal) => ({
}), }),
})); }));
import { hasDeprecatedProviderConfig } from '../features/tasks/list/requeueHelpers.js'; vi.mock('../shared/prompt/index.js', () => ({
confirm: (...args: unknown[]) => mockConfirm(...args),
}));
vi.mock('../shared/i18n/index.js', () => ({
getLabel: (...args: unknown[]) => mockGetLabel(...args),
}));
vi.mock('../features/pieceSelection/index.js', () => ({
selectPiece: (...args: unknown[]) => mockSelectPiece(...args),
}));
vi.mock('../infra/config/index.js', async (importOriginal) => ({
...(await importOriginal<Record<string, unknown>>()),
isPiecePath: (...args: unknown[]) => mockIsPiecePath(...args),
loadAllPiecesWithSources: (...args: unknown[]) => mockLoadAllPiecesWithSources(...args),
}));
import { hasDeprecatedProviderConfig, selectPieceWithOptionalReuse } from '../features/tasks/list/requeueHelpers.js';
describe('hasDeprecatedProviderConfig', () => { describe('hasDeprecatedProviderConfig', () => {
beforeEach(() => { beforeEach(() => {
@ -86,3 +116,48 @@ describe('hasDeprecatedProviderConfig', () => {
expect(hasDeprecatedProviderConfig(orderContent)).toBe(false); expect(hasDeprecatedProviderConfig(orderContent)).toBe(false);
}); });
}); });
describe('selectPieceWithOptionalReuse', () => {
beforeEach(() => {
vi.clearAllMocks();
mockIsPiecePath.mockReturnValue(false);
mockLoadAllPiecesWithSources.mockReturnValue(new Map<string, unknown>([['default', {}], ['selected-piece', {}]]));
mockSelectPiece.mockResolvedValue('selected-piece');
});
it('内部ヘルパーを公開 API に露出しない', async () => {
const requeueHelpersModule = await import('../features/tasks/list/requeueHelpers.js');
expect(Object.prototype.hasOwnProperty.call(requeueHelpersModule, 'resolveReusablePieceName')).toBe(false);
});
it('前回 piece 再利用を確認して Yes ならそのまま返す', async () => {
mockConfirm.mockResolvedValue(true);
const selected = await selectPieceWithOptionalReuse('/project', 'default', 'en');
expect(selected).toBe('default');
expect(mockConfirm).toHaveBeenCalledTimes(1);
expect(mockSelectPiece).not.toHaveBeenCalled();
});
it('前回 piece 再利用を拒否した場合は piece 選択にフォールバックする', async () => {
mockConfirm.mockResolvedValue(false);
const selected = await selectPieceWithOptionalReuse('/project', 'default', 'en');
expect(selected).toBe('selected-piece');
expect(mockConfirm).toHaveBeenCalledTimes(1);
expect(mockSelectPiece).toHaveBeenCalledWith('/project');
});
it('未登録の前回 piece 名は確認せず拒否して piece 選択にフォールバックする', async () => {
mockLoadAllPiecesWithSources.mockReturnValue(new Map<string, unknown>([['default', {}]]));
const selected = await selectPieceWithOptionalReuse('/project', 'tampered-piece', 'en');
expect(selected).toBe('selected-piece');
expect(mockConfirm).not.toHaveBeenCalled();
expect(mockSelectPiece).toHaveBeenCalledWith('/project');
});
});

View File

@ -14,8 +14,11 @@ const {
mockListRecentRuns, mockListRecentRuns,
mockSelectRun, mockSelectRun,
mockLoadRunSessionContext, mockLoadRunSessionContext,
mockFindRunForTask,
mockFindPreviousOrderContent, mockFindPreviousOrderContent,
mockWarn, mockWarn,
mockIsPiecePath,
mockLoadAllPiecesWithSources,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
mockExistsSync: vi.fn(() => true), mockExistsSync: vi.fn(() => true),
mockStartReExecution: vi.fn(), mockStartReExecution: vi.fn(),
@ -30,8 +33,14 @@ const {
mockListRecentRuns: vi.fn(() => []), mockListRecentRuns: vi.fn(() => []),
mockSelectRun: vi.fn(() => null), mockSelectRun: vi.fn(() => null),
mockLoadRunSessionContext: vi.fn(), mockLoadRunSessionContext: vi.fn(),
mockFindRunForTask: vi.fn(() => null),
mockFindPreviousOrderContent: vi.fn(() => null), mockFindPreviousOrderContent: vi.fn(() => null),
mockWarn: vi.fn(), mockWarn: vi.fn(),
mockIsPiecePath: vi.fn(() => false),
mockLoadAllPiecesWithSources: vi.fn(() => new Map<string, unknown>([
['default', {}],
['selected-piece', {}],
])),
})); }));
vi.mock('node:fs', async (importOriginal) => ({ vi.mock('node:fs', async (importOriginal) => ({
@ -59,6 +68,8 @@ vi.mock('../infra/config/index.js', () => ({
pieceStructure: [], pieceStructure: [],
movementPreviews: [], movementPreviews: [],
})), })),
isPiecePath: (...args: unknown[]) => mockIsPiecePath(...args),
loadAllPiecesWithSources: (...args: unknown[]) => mockLoadAllPiecesWithSources(...args),
})); }));
vi.mock('../features/tasks/list/instructMode.js', () => ({ vi.mock('../features/tasks/list/instructMode.js', () => ({
@ -86,7 +97,7 @@ vi.mock('../features/interactive/index.js', () => ({
listRecentRuns: (...args: unknown[]) => mockListRecentRuns(...args), listRecentRuns: (...args: unknown[]) => mockListRecentRuns(...args),
selectRun: (...args: unknown[]) => mockSelectRun(...args), selectRun: (...args: unknown[]) => mockSelectRun(...args),
loadRunSessionContext: (...args: unknown[]) => mockLoadRunSessionContext(...args), loadRunSessionContext: (...args: unknown[]) => mockLoadRunSessionContext(...args),
findRunForTask: vi.fn(() => null), findRunForTask: (...args: unknown[]) => mockFindRunForTask(...args),
findPreviousOrderContent: (...args: unknown[]) => mockFindPreviousOrderContent(...args), findPreviousOrderContent: (...args: unknown[]) => mockFindPreviousOrderContent(...args),
})); }));
@ -123,11 +134,25 @@ describe('instructBranch direct execution flow', () => {
mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' }); mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' });
mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.execute({ task: '追加指示A' })); mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.execute({ task: '追加指示A' }));
mockConfirm.mockResolvedValue(true); mockConfirm.mockResolvedValue(true);
mockGetLabel.mockReturnValue("Reference a previous run's results?"); mockGetLabel.mockImplementation((key: string, _lang?: string, vars?: Record<string, string>) => {
if (key === 'interactive.runSelector.confirm') {
return "Reference a previous run's results?";
}
if (vars?.piece) {
return `Use previous piece "${vars.piece}"?`;
}
return key;
});
mockResolveLanguage.mockReturnValue('en'); mockResolveLanguage.mockReturnValue('en');
mockListRecentRuns.mockReturnValue([]); mockListRecentRuns.mockReturnValue([]);
mockSelectRun.mockResolvedValue(null); mockSelectRun.mockResolvedValue(null);
mockFindRunForTask.mockReturnValue(null);
mockFindPreviousOrderContent.mockReturnValue(null); mockFindPreviousOrderContent.mockReturnValue(null);
mockIsPiecePath.mockImplementation((piece: string) => piece.startsWith('/') || piece.startsWith('~') || piece.startsWith('./') || piece.startsWith('../') || piece.endsWith('.yaml') || piece.endsWith('.yml'));
mockLoadAllPiecesWithSources.mockReturnValue(new Map<string, unknown>([
['default', {}],
['selected-piece', {}],
]));
mockStartReExecution.mockReturnValue({ mockStartReExecution.mockReturnValue({
name: 'done-task', name: 'done-task',
content: 'done', content: 'done',
@ -185,6 +210,117 @@ describe('instructBranch direct execution flow', () => {
expect(originalTaskInfo.data.piece).toBe('original-piece'); expect(originalTaskInfo.data.piece).toBe('original-piece');
}); });
it('should reuse previous piece when confirmed', async () => {
mockFindRunForTask.mockReturnValue('run-previous');
mockLoadRunSessionContext.mockReturnValue({
task: 'done',
piece: 'default',
status: 'completed',
movementLogs: [],
reports: [],
});
mockConfirm
.mockResolvedValueOnce(true);
await instructBranch('/project', {
kind: 'completed',
name: 'done-task',
createdAt: '2026-02-14T00:00:00.000Z',
filePath: '/project/.takt/tasks.yaml',
content: 'done',
branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task',
data: { task: 'done' },
});
expect(mockSelectPiece).not.toHaveBeenCalled();
const [message, defaultYes] = mockConfirm.mock.calls[0] ?? [];
expect(message).toEqual(expect.stringContaining('"default"'));
expect(defaultYes ?? true).toBe(true);
});
it('should call selectPiece when previous piece reuse is declined', async () => {
mockFindRunForTask.mockReturnValue('run-previous');
mockLoadRunSessionContext.mockReturnValue({
task: 'done',
piece: 'default',
status: 'completed',
movementLogs: [],
reports: [],
});
mockConfirm
.mockResolvedValueOnce(false);
mockSelectPiece.mockResolvedValue('selected-piece');
await instructBranch('/project', {
kind: 'completed',
name: 'done-task',
createdAt: '2026-02-14T00:00:00.000Z',
filePath: '/project/.takt/tasks.yaml',
content: 'done',
branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task',
data: { task: 'done' },
});
expect(mockSelectPiece).toHaveBeenCalledWith('/project');
expect(mockStartReExecution).toHaveBeenCalled();
});
it('should ignore previous piece when run metadata contains piece path', async () => {
mockFindRunForTask.mockReturnValue('run-previous');
mockLoadRunSessionContext.mockReturnValue({
task: 'done',
piece: '../secrets.yaml',
status: 'completed',
movementLogs: [],
reports: [],
});
mockSelectPiece.mockResolvedValue('selected-piece');
await instructBranch('/project', {
kind: 'completed',
name: 'done-task',
createdAt: '2026-02-14T00:00:00.000Z',
filePath: '/project/.takt/tasks.yaml',
content: 'done',
branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task',
data: { task: 'done' },
});
expect(mockConfirm).not.toHaveBeenCalled();
expect(mockSelectPiece).toHaveBeenCalledWith('/project');
expect(mockStartReExecution).toHaveBeenCalled();
});
it('should return false when replacement piece selection is cancelled after declining reuse', async () => {
mockFindRunForTask.mockReturnValue('run-previous');
mockLoadRunSessionContext.mockReturnValue({
task: 'done',
piece: 'default',
status: 'completed',
movementLogs: [],
reports: [],
});
mockConfirm.mockResolvedValueOnce(false);
mockSelectPiece.mockResolvedValue(null);
const result = await instructBranch('/project', {
kind: 'completed',
name: 'done-task',
createdAt: '2026-02-14T00:00:00.000Z',
filePath: '/project/.takt/tasks.yaml',
content: 'done',
branch: 'takt/done-task',
worktreePath: '/project/.takt/worktrees/done-task',
data: { task: 'done' },
});
expect(result).toBe(false);
expect(mockStartReExecution).not.toHaveBeenCalled();
});
it('should set generated instruction as retry note when no existing note', async () => { it('should set generated instruction as retry note when no existing note', async () => {
await instructBranch('/project', { await instructBranch('/project', {
kind: 'completed', kind: 'completed',

View File

@ -4,20 +4,26 @@ const {
mockExistsSync, mockExistsSync,
mockSelectPiece, mockSelectPiece,
mockSelectOptionWithDefault, mockSelectOptionWithDefault,
mockConfirm,
mockResolvePieceConfigValue, mockResolvePieceConfigValue,
mockLoadPieceByIdentifier, mockLoadPieceByIdentifier,
mockGetPieceDescription, mockGetPieceDescription,
mockRunRetryMode, mockRunRetryMode,
mockFindRunForTask, mockFindRunForTask,
mockFindPreviousOrderContent, mockFindPreviousOrderContent,
mockLoadRunSessionContext,
mockFormatRunSessionForPrompt,
mockStartReExecution, mockStartReExecution,
mockRequeueTask, mockRequeueTask,
mockExecuteAndCompleteTask, mockExecuteAndCompleteTask,
mockWarn, mockWarn,
mockIsPiecePath,
mockLoadAllPiecesWithSources,
} = vi.hoisted(() => ({ } = vi.hoisted(() => ({
mockExistsSync: vi.fn(() => true), mockExistsSync: vi.fn(() => true),
mockSelectPiece: vi.fn(), mockSelectPiece: vi.fn(),
mockSelectOptionWithDefault: vi.fn(), mockSelectOptionWithDefault: vi.fn(),
mockConfirm: vi.fn(),
mockResolvePieceConfigValue: vi.fn(), mockResolvePieceConfigValue: vi.fn(),
mockLoadPieceByIdentifier: vi.fn(), mockLoadPieceByIdentifier: vi.fn(),
mockGetPieceDescription: vi.fn(() => ({ mockGetPieceDescription: vi.fn(() => ({
@ -29,10 +35,20 @@ const {
mockRunRetryMode: vi.fn(), mockRunRetryMode: vi.fn(),
mockFindRunForTask: vi.fn(() => null), mockFindRunForTask: vi.fn(() => null),
mockFindPreviousOrderContent: vi.fn(() => null), mockFindPreviousOrderContent: vi.fn(() => null),
mockLoadRunSessionContext: vi.fn(),
mockFormatRunSessionForPrompt: vi.fn((sessionContext?: { piece?: string }) => ({
runTask: '',
runPiece: sessionContext?.piece ?? '',
runStatus: '',
runMovementLogs: '',
runReports: '',
})),
mockStartReExecution: vi.fn(), mockStartReExecution: vi.fn(),
mockRequeueTask: vi.fn(), mockRequeueTask: vi.fn(),
mockExecuteAndCompleteTask: vi.fn(), mockExecuteAndCompleteTask: vi.fn(),
mockWarn: vi.fn(), mockWarn: vi.fn(),
mockIsPiecePath: vi.fn(() => false),
mockLoadAllPiecesWithSources: vi.fn(() => new Map<string, unknown>([['default', {}]])),
})); }));
vi.mock('node:fs', async (importOriginal) => ({ vi.mock('node:fs', async (importOriginal) => ({
@ -46,6 +62,7 @@ vi.mock('../features/pieceSelection/index.js', () => ({
vi.mock('../shared/prompt/index.js', () => ({ vi.mock('../shared/prompt/index.js', () => ({
selectOptionWithDefault: (...args: unknown[]) => mockSelectOptionWithDefault(...args), selectOptionWithDefault: (...args: unknown[]) => mockSelectOptionWithDefault(...args),
confirm: (...args: unknown[]) => mockConfirm(...args),
})); }));
vi.mock('../shared/ui/index.js', () => ({ vi.mock('../shared/ui/index.js', () => ({
@ -68,19 +85,15 @@ vi.mock('../infra/config/index.js', () => ({
resolvePieceConfigValue: (...args: unknown[]) => mockResolvePieceConfigValue(...args), resolvePieceConfigValue: (...args: unknown[]) => mockResolvePieceConfigValue(...args),
loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args), loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args),
getPieceDescription: (...args: unknown[]) => mockGetPieceDescription(...args), getPieceDescription: (...args: unknown[]) => mockGetPieceDescription(...args),
isPiecePath: (...args: unknown[]) => mockIsPiecePath(...args),
loadAllPiecesWithSources: (...args: unknown[]) => mockLoadAllPiecesWithSources(...args),
})); }));
vi.mock('../features/interactive/index.js', () => ({ vi.mock('../features/interactive/index.js', () => ({
findRunForTask: (...args: unknown[]) => mockFindRunForTask(...args), findRunForTask: (...args: unknown[]) => mockFindRunForTask(...args),
loadRunSessionContext: vi.fn(), loadRunSessionContext: (...args: unknown[]) => mockLoadRunSessionContext(...args),
getRunPaths: vi.fn(() => ({ logsDir: '/tmp/logs', reportsDir: '/tmp/reports' })), getRunPaths: vi.fn(() => ({ logsDir: '/tmp/logs', reportsDir: '/tmp/reports' })),
formatRunSessionForPrompt: vi.fn(() => ({ formatRunSessionForPrompt: (...args: unknown[]) => mockFormatRunSessionForPrompt(...args),
runTask: '',
runPiece: 'default',
runStatus: '',
runMovementLogs: '',
runReports: '',
})),
runRetryMode: (...args: unknown[]) => mockRunRetryMode(...args), runRetryMode: (...args: unknown[]) => mockRunRetryMode(...args),
findPreviousOrderContent: (...args: unknown[]) => mockFindPreviousOrderContent(...args), findPreviousOrderContent: (...args: unknown[]) => mockFindPreviousOrderContent(...args),
})); }));
@ -101,13 +114,11 @@ vi.mock('../features/tasks/execute/taskExecution.js', () => ({
})); }));
vi.mock('../shared/i18n/index.js', () => ({ vi.mock('../shared/i18n/index.js', () => ({
getLabel: vi.fn((key: string) => { getLabel: vi.fn((key: string, _lang?: string, vars?: Record<string, string>) => {
const labels: Record<string, string> = { if (vars?.piece) {
'retry.workflowPrompt': 'Select workflow:', return `Use previous piece "${vars.piece}"?`;
'retry.usePreviousWorkflow': 'Use previous', }
'retry.changeWorkflow': 'Change workflow', return key;
};
return labels[key] ?? key;
}), }),
})); }));
@ -146,12 +157,22 @@ beforeEach(() => {
vi.clearAllMocks(); vi.clearAllMocks();
mockExistsSync.mockReturnValue(true); mockExistsSync.mockReturnValue(true);
mockConfirm.mockResolvedValue(true);
mockSelectPiece.mockResolvedValue('default'); mockSelectPiece.mockResolvedValue('default');
mockResolvePieceConfigValue.mockReturnValue(3); mockResolvePieceConfigValue.mockReturnValue(3);
mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig); mockLoadPieceByIdentifier.mockReturnValue(defaultPieceConfig);
mockIsPiecePath.mockImplementation((piece: string) => piece.startsWith('/') || piece.startsWith('~') || piece.startsWith('./') || piece.startsWith('../') || piece.endsWith('.yaml') || piece.endsWith('.yml'));
mockLoadAllPiecesWithSources.mockReturnValue(new Map<string, unknown>([['default', {}], ['selected-piece', {}]]));
mockSelectOptionWithDefault.mockResolvedValue('plan'); mockSelectOptionWithDefault.mockResolvedValue('plan');
mockRunRetryMode.mockResolvedValue({ action: 'execute', task: '追加指示A' }); mockRunRetryMode.mockResolvedValue({ action: 'execute', task: '追加指示A' });
mockFindPreviousOrderContent.mockReturnValue(null); mockFindPreviousOrderContent.mockReturnValue(null);
mockLoadRunSessionContext.mockReturnValue({
task: 'Do something',
piece: 'default',
status: 'failed',
movementLogs: [],
reports: [],
});
mockStartReExecution.mockReturnValue({ mockStartReExecution.mockReturnValue({
name: 'my-task', name: 'my-task',
content: 'Do something', content: 'Do something',
@ -337,29 +358,24 @@ describe('retryFailedTask', () => {
expect(mockRequeueTask).toHaveBeenCalledWith('my-task', ['failed'], undefined, '既存ノート\n\n追加指示A'); expect(mockRequeueTask).toHaveBeenCalledWith('my-task', ['failed'], undefined, '既存ノート\n\n追加指示A');
}); });
describe('when previous workflow exists', () => { describe('when previous piece exists', () => {
beforeEach(() => { beforeEach(() => {
mockFindRunForTask.mockReturnValue('run-123'); mockFindRunForTask.mockReturnValue('run-123');
}); });
it('should show workflow selection prompt when runInfo.piece exists', async () => { it('should ask whether to reuse previous piece with default yes', async () => {
const task = makeFailedTask(); const task = makeFailedTask();
await retryFailedTask(task, '/project'); await retryFailedTask(task, '/project');
expect(mockSelectOptionWithDefault).toHaveBeenCalledWith( const [message, defaultYes] = mockConfirm.mock.calls[0] ?? [];
'Select workflow:', expect(message).toEqual(expect.stringContaining('"default"'));
expect.arrayContaining([ expect(defaultYes ?? true).toBe(true);
expect.objectContaining({ value: 'use_previous' }),
expect.objectContaining({ value: 'change' }),
]),
'use_previous',
);
}); });
it('should use previous workflow when use_previous is selected', async () => { it('should use previous piece when reuse is confirmed', async () => {
const task = makeFailedTask(); const task = makeFailedTask();
mockSelectOptionWithDefault.mockResolvedValue('use_previous'); mockConfirm.mockResolvedValue(true);
await retryFailedTask(task, '/project'); await retryFailedTask(task, '/project');
@ -367,23 +383,41 @@ describe('retryFailedTask', () => {
expect(mockLoadPieceByIdentifier).toHaveBeenCalledWith('default', '/project'); expect(mockLoadPieceByIdentifier).toHaveBeenCalledWith('default', '/project');
}); });
it('should call selectPiece when change is selected', async () => { it('should call selectPiece when reuse is declined', async () => {
const task = makeFailedTask(); const task = makeFailedTask();
mockSelectOptionWithDefault.mockResolvedValue('change'); mockConfirm.mockResolvedValue(false);
await retryFailedTask(task, '/project'); await retryFailedTask(task, '/project');
expect(mockSelectPiece).toHaveBeenCalledWith('/project'); expect(mockSelectPiece).toHaveBeenCalledWith('/project');
}); });
it('should return false when workflow selection is cancelled', async () => { it('should return false when selecting replacement piece is cancelled after declining reuse', async () => {
const task = makeFailedTask(); const task = makeFailedTask();
mockSelectOptionWithDefault.mockResolvedValue(null); mockConfirm.mockResolvedValue(false);
mockSelectPiece.mockResolvedValue(null);
const result = await retryFailedTask(task, '/project'); const result = await retryFailedTask(task, '/project');
expect(result).toBe(false); expect(result).toBe(false);
expect(mockLoadPieceByIdentifier).not.toHaveBeenCalled(); expect(mockLoadPieceByIdentifier).not.toHaveBeenCalled();
}); });
it('should ignore previous piece when run metadata contains piece path', async () => {
const task = makeFailedTask();
mockLoadRunSessionContext.mockReturnValue({
task: 'Do something',
piece: '../secrets.yaml',
status: 'failed',
movementLogs: [],
reports: [],
});
await retryFailedTask(task, '/project');
expect(mockConfirm).not.toHaveBeenCalled();
expect(mockSelectPiece).toHaveBeenCalledWith('/project');
expect(mockLoadPieceByIdentifier).toHaveBeenCalledWith('default', '/project');
});
}); });
}); });

View File

@ -1,6 +1,8 @@
import { confirm } from '../../../shared/prompt/index.js'; import { confirm } from '../../../shared/prompt/index.js';
import { getLabel } from '../../../shared/i18n/index.js'; import { getLabel } from '../../../shared/i18n/index.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { isPiecePath, loadAllPiecesWithSources } from '../../../infra/config/index.js';
import { selectPiece } from '../../pieceSelection/index.js';
import { parse as parseYaml } from 'yaml'; import { parse as parseYaml } from 'yaml';
import { import {
selectRun, selectRun,
@ -24,6 +26,42 @@ export function appendRetryNote(existing: string | undefined, additional: string
return `${existing}\n\n${trimmedAdditional}`; return `${existing}\n\n${trimmedAdditional}`;
} }
function resolveReusablePieceName(
previousPiece: string | undefined,
projectDir: string,
): string | null {
if (!previousPiece || previousPiece.trim() === '') {
return null;
}
if (isPiecePath(previousPiece)) {
return null;
}
const availablePieces = loadAllPiecesWithSources(projectDir);
if (!availablePieces.has(previousPiece)) {
return null;
}
return previousPiece;
}
export async function selectPieceWithOptionalReuse(
projectDir: string,
previousPiece: string | undefined,
lang?: 'en' | 'ja',
): Promise<string | null> {
const reusablePiece = resolveReusablePieceName(previousPiece, projectDir);
if (reusablePiece) {
const shouldReusePreviousPiece = await confirm(
getLabel('retry.usePreviousPieceConfirm', lang, { piece: reusablePiece }),
true,
);
if (shouldReusePreviousPiece) {
return reusablePiece;
}
}
return selectPiece(projectDir);
}
function extractYamlCandidates(content: string): string[] { function extractYamlCandidates(content: string): string[] {
const blockPattern = /```(?:yaml|yml)\s*\n([\s\S]*?)```/gi; const blockPattern = /```(?:yaml|yml)\s*\n([\s\S]*?)```/gi;
const candidates: string[] = []; const candidates: string[] = [];

View File

@ -15,15 +15,15 @@ import { resolvePieceConfigValues, getPieceDescription } from '../../../infra/co
import { info, warn, error as logError } from '../../../shared/ui/index.js'; import { info, warn, error as logError } from '../../../shared/ui/index.js';
import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js';
import { runInstructMode } from './instructMode.js'; import { runInstructMode } from './instructMode.js';
import { selectPiece } from '../../pieceSelection/index.js';
import { dispatchConversationAction } from '../../interactive/actionDispatcher.js'; import { dispatchConversationAction } from '../../interactive/actionDispatcher.js';
import type { PieceContext } from '../../interactive/interactive.js'; import type { PieceContext } from '../../interactive/interactive.js';
import { resolveLanguage, findRunForTask, findPreviousOrderContent } from '../../interactive/index.js'; import { resolveLanguage, findRunForTask, findPreviousOrderContent, loadRunSessionContext } from '../../interactive/index.js';
import { type BranchActionTarget, resolveTargetBranch } from './taskActionTarget.js'; import { type BranchActionTarget, resolveTargetBranch } from './taskActionTarget.js';
import { import {
appendRetryNote, appendRetryNote,
DEPRECATED_PROVIDER_CONFIG_WARNING, DEPRECATED_PROVIDER_CONFIG_WARNING,
hasDeprecatedProviderConfig, hasDeprecatedProviderConfig,
selectPieceWithOptionalReuse,
selectRunSessionContext, selectRunSessionContext,
} from './requeueHelpers.js'; } from './requeueHelpers.js';
import { executeAndCompleteTask } from '../execute/taskExecution.js'; import { executeAndCompleteTask } from '../execute/taskExecution.js';
@ -93,13 +93,18 @@ export async function instructBranch(
const branch = resolveTargetBranch(target); const branch = resolveTargetBranch(target);
const selectedPiece = await selectPiece(projectDir); const globalConfig = resolvePieceConfigValues(projectDir, ['interactivePreviewMovements', 'language']);
const lang = resolveLanguage(globalConfig.language);
const matchedSlug = findRunForTask(worktreePath, target.content);
const previousRunContext = matchedSlug
? loadRunSessionContext(worktreePath, matchedSlug)
: undefined;
const selectedPiece = await selectPieceWithOptionalReuse(projectDir, previousRunContext?.piece, lang);
if (!selectedPiece) { if (!selectedPiece) {
info('Cancelled'); info('Cancelled');
return false; return false;
} }
const globalConfig = resolvePieceConfigValues(projectDir, ['interactivePreviewMovements', 'language']);
const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements); const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements);
const pieceContext: PieceContext = { const pieceContext: PieceContext = {
name: pieceDesc.name, name: pieceDesc.name,
@ -108,10 +113,8 @@ export async function instructBranch(
movementPreviews: pieceDesc.movementPreviews, movementPreviews: pieceDesc.movementPreviews,
}; };
const lang = resolveLanguage(globalConfig.language);
// Runs data lives in the worktree (written during previous execution) // Runs data lives in the worktree (written during previous execution)
const runSessionContext = await selectRunSessionContext(worktreePath, lang); const runSessionContext = await selectRunSessionContext(worktreePath, lang);
const matchedSlug = findRunForTask(worktreePath, target.content);
const previousOrderContent = findPreviousOrderContent(worktreePath, matchedSlug); const previousOrderContent = findPreviousOrderContent(worktreePath, matchedSlug);
if (hasDeprecatedProviderConfig(previousOrderContent)) { if (hasDeprecatedProviderConfig(previousOrderContent)) {
warn(DEPRECATED_PROVIDER_CONFIG_WARNING); warn(DEPRECATED_PROVIDER_CONFIG_WARNING);

View File

@ -9,9 +9,7 @@ import * as fs from 'node:fs';
import type { TaskListItem } from '../../../infra/task/index.js'; import type { TaskListItem } from '../../../infra/task/index.js';
import { TaskRunner } from '../../../infra/task/index.js'; import { TaskRunner } from '../../../infra/task/index.js';
import { loadPieceByIdentifier, resolvePieceConfigValue, getPieceDescription } from '../../../infra/config/index.js'; import { loadPieceByIdentifier, resolvePieceConfigValue, getPieceDescription } from '../../../infra/config/index.js';
import { selectPiece } from '../../pieceSelection/index.js';
import { selectOptionWithDefault } from '../../../shared/prompt/index.js'; import { selectOptionWithDefault } from '../../../shared/prompt/index.js';
import { getLabel } from '../../../shared/i18n/index.js';
import { info, header, blankLine, status, warn } from '../../../shared/ui/index.js'; import { info, header, blankLine, status, warn } from '../../../shared/ui/index.js';
import { createLogger } from '../../../shared/utils/index.js'; import { createLogger } from '../../../shared/utils/index.js';
import type { PieceConfig } from '../../../core/models/index.js'; import type { PieceConfig } from '../../../core/models/index.js';
@ -31,6 +29,7 @@ import {
appendRetryNote, appendRetryNote,
DEPRECATED_PROVIDER_CONFIG_WARNING, DEPRECATED_PROVIDER_CONFIG_WARNING,
hasDeprecatedProviderConfig, hasDeprecatedProviderConfig,
selectPieceWithOptionalReuse,
} from './requeueHelpers.js'; } from './requeueHelpers.js';
import { prepareTaskForExecution } from './prepareTaskForExecution.js'; import { prepareTaskForExecution } from './prepareTaskForExecution.js';
@ -137,41 +136,10 @@ export async function retryFailedTask(
const matchedSlug = findRunForTask(worktreePath, task.content); const matchedSlug = findRunForTask(worktreePath, task.content);
const runInfo = matchedSlug ? buildRetryRunInfo(worktreePath, matchedSlug) : null; const runInfo = matchedSlug ? buildRetryRunInfo(worktreePath, matchedSlug) : null;
let selectedPiece: string; const selectedPiece = await selectPieceWithOptionalReuse(projectDir, runInfo?.piece);
if (runInfo?.piece) { if (!selectedPiece) {
const usePreviousLabel = getLabel('retry.usePreviousWorkflow'); info('Cancelled');
const changeWorkflowLabel = getLabel('retry.changeWorkflow'); return false;
const choice = await selectOptionWithDefault(
getLabel('retry.workflowPrompt'),
[
{ label: `${runInfo.piece} - ${usePreviousLabel}`, value: 'use_previous' },
{ label: changeWorkflowLabel, value: 'change' },
],
'use_previous',
);
if (choice === null) {
info('Cancelled');
return false;
}
if (choice === 'use_previous') {
selectedPiece = runInfo.piece;
} else {
const selected = await selectPiece(projectDir);
if (!selected) {
info('Cancelled');
return false;
}
selectedPiece = selected;
}
} else {
const selected = await selectPiece(projectDir);
if (!selected) {
info('Cancelled');
return false;
}
selectedPiece = selected;
} }
const previewCount = resolvePieceConfigValue(projectDir, 'interactivePreviewMovements'); const previewCount = resolvePieceConfigValue(projectDir, 'interactivePreviewMovements');

View File

@ -99,9 +99,7 @@ instruct:
retry: retry:
ui: ui:
intro: "Retry mode - describe additional instructions. Commands: /go (create instruction & run), /retry (rerun previous order), /cancel (exit)" intro: "Retry mode - describe additional instructions. Commands: /go (create instruction & run), /retry (rerun previous order), /cancel (exit)"
workflowPrompt: "Select workflow:" usePreviousPieceConfirm: "Use previous piece \"{piece}\"?"
usePreviousWorkflow: "Use previous"
changeWorkflow: "Change workflow"
run: run:
notifyComplete: "Run complete ({total} tasks)" notifyComplete: "Run complete ({total} tasks)"

View File

@ -99,9 +99,7 @@ instruct:
retry: retry:
ui: ui:
intro: "リトライモード - 追加指示を入力してください。コマンド: /go指示書作成・実行, /retry前回の指示書で再実行, /cancel終了" intro: "リトライモード - 追加指示を入力してください。コマンド: /go指示書作成・実行, /retry前回の指示書で再実行, /cancel終了"
workflowPrompt: "ワークフローを選択:" usePreviousPieceConfirm: "前回のピース \"{piece}\" を使用しますか?"
usePreviousWorkflow: "前回のまま使用"
changeWorkflow: "ワークフローを変更"
run: run:
notifyComplete: "run完了 ({total} tasks)" notifyComplete: "run完了 ({total} tasks)"