From 3649ce40f9c3fd5dd8d5654ccd9f6be0dea63120 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Wed, 4 Mar 2026 23:07:47 +0900 Subject: [PATCH] takt: add-piece-reuse-confirm (#468) --- src/__tests__/i18n.test.ts | 11 ++ src/__tests__/requeueHelpers.test.ts | 79 +++++++++- src/__tests__/taskInstructionActions.test.ts | 140 +++++++++++++++++- src/__tests__/taskRetryActions.test.ts | 96 ++++++++---- src/features/tasks/list/requeueHelpers.ts | 38 +++++ .../tasks/list/taskInstructionActions.ts | 15 +- src/features/tasks/list/taskRetryActions.ts | 42 +----- src/shared/i18n/labels_en.yaml | 4 +- src/shared/i18n/labels_ja.yaml | 4 +- 9 files changed, 345 insertions(+), 84 deletions(-) diff --git a/src/__tests__/i18n.test.ts b/src/__tests__/i18n.test.ts index 5e962b7..0fd2262 100644 --- a/src/__tests__/i18n.test.ts +++ b/src/__tests__/i18n.test.ts @@ -139,4 +139,15 @@ describe('label integrity', () => { 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'); + }); }); diff --git a/src/__tests__/requeueHelpers.test.ts b/src/__tests__/requeueHelpers.test.ts index 0caf472..eaffa38 100644 --- a/src/__tests__/requeueHelpers.test.ts +++ b/src/__tests__/requeueHelpers.test.ts @@ -1,7 +1,19 @@ import { describe, expect, it, vi, beforeEach } from 'vitest'; -const { mockDebug } = vi.hoisted(() => ({ +const { + mockDebug, + mockConfirm, + mockGetLabel, + mockSelectPiece, + mockIsPiecePath, + mockLoadAllPiecesWithSources, +} = vi.hoisted(() => ({ mockDebug: vi.fn(), + mockConfirm: vi.fn(), + mockGetLabel: vi.fn((_key: string, _lang?: string, vars?: Record) => `Use previous piece "${vars?.piece ?? ''}"?`), + mockSelectPiece: vi.fn(), + mockIsPiecePath: vi.fn(() => false), + mockLoadAllPiecesWithSources: vi.fn(() => new Map([['default', {}], ['selected-piece', {}]])), })); 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>()), + isPiecePath: (...args: unknown[]) => mockIsPiecePath(...args), + loadAllPiecesWithSources: (...args: unknown[]) => mockLoadAllPiecesWithSources(...args), +})); + +import { hasDeprecatedProviderConfig, selectPieceWithOptionalReuse } from '../features/tasks/list/requeueHelpers.js'; describe('hasDeprecatedProviderConfig', () => { beforeEach(() => { @@ -86,3 +116,48 @@ describe('hasDeprecatedProviderConfig', () => { expect(hasDeprecatedProviderConfig(orderContent)).toBe(false); }); }); + +describe('selectPieceWithOptionalReuse', () => { + beforeEach(() => { + vi.clearAllMocks(); + mockIsPiecePath.mockReturnValue(false); + mockLoadAllPiecesWithSources.mockReturnValue(new Map([['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([['default', {}]])); + + const selected = await selectPieceWithOptionalReuse('/project', 'tampered-piece', 'en'); + + expect(selected).toBe('selected-piece'); + expect(mockConfirm).not.toHaveBeenCalled(); + expect(mockSelectPiece).toHaveBeenCalledWith('/project'); + }); +}); diff --git a/src/__tests__/taskInstructionActions.test.ts b/src/__tests__/taskInstructionActions.test.ts index 5571941..92047f2 100644 --- a/src/__tests__/taskInstructionActions.test.ts +++ b/src/__tests__/taskInstructionActions.test.ts @@ -14,8 +14,11 @@ const { mockListRecentRuns, mockSelectRun, mockLoadRunSessionContext, + mockFindRunForTask, mockFindPreviousOrderContent, mockWarn, + mockIsPiecePath, + mockLoadAllPiecesWithSources, } = vi.hoisted(() => ({ mockExistsSync: vi.fn(() => true), mockStartReExecution: vi.fn(), @@ -30,8 +33,14 @@ const { mockListRecentRuns: vi.fn(() => []), mockSelectRun: vi.fn(() => null), mockLoadRunSessionContext: vi.fn(), + mockFindRunForTask: vi.fn(() => null), mockFindPreviousOrderContent: vi.fn(() => null), mockWarn: vi.fn(), + mockIsPiecePath: vi.fn(() => false), + mockLoadAllPiecesWithSources: vi.fn(() => new Map([ + ['default', {}], + ['selected-piece', {}], + ])), })); vi.mock('node:fs', async (importOriginal) => ({ @@ -59,6 +68,8 @@ vi.mock('../infra/config/index.js', () => ({ pieceStructure: [], movementPreviews: [], })), + isPiecePath: (...args: unknown[]) => mockIsPiecePath(...args), + loadAllPiecesWithSources: (...args: unknown[]) => mockLoadAllPiecesWithSources(...args), })); vi.mock('../features/tasks/list/instructMode.js', () => ({ @@ -86,7 +97,7 @@ vi.mock('../features/interactive/index.js', () => ({ listRecentRuns: (...args: unknown[]) => mockListRecentRuns(...args), selectRun: (...args: unknown[]) => mockSelectRun(...args), loadRunSessionContext: (...args: unknown[]) => mockLoadRunSessionContext(...args), - findRunForTask: vi.fn(() => null), + findRunForTask: (...args: unknown[]) => mockFindRunForTask(...args), findPreviousOrderContent: (...args: unknown[]) => mockFindPreviousOrderContent(...args), })); @@ -123,11 +134,25 @@ describe('instructBranch direct execution flow', () => { mockRunInstructMode.mockResolvedValue({ action: 'execute', task: '追加指示A' }); mockDispatchConversationAction.mockImplementation(async (_result, handlers) => handlers.execute({ task: '追加指示A' })); mockConfirm.mockResolvedValue(true); - mockGetLabel.mockReturnValue("Reference a previous run's results?"); + mockGetLabel.mockImplementation((key: string, _lang?: string, vars?: Record) => { + 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'); mockListRecentRuns.mockReturnValue([]); mockSelectRun.mockResolvedValue(null); + mockFindRunForTask.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([ + ['default', {}], + ['selected-piece', {}], + ])); mockStartReExecution.mockReturnValue({ name: 'done-task', content: 'done', @@ -185,6 +210,117 @@ describe('instructBranch direct execution flow', () => { 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 () => { await instructBranch('/project', { kind: 'completed', diff --git a/src/__tests__/taskRetryActions.test.ts b/src/__tests__/taskRetryActions.test.ts index 286c235..13b5d16 100644 --- a/src/__tests__/taskRetryActions.test.ts +++ b/src/__tests__/taskRetryActions.test.ts @@ -4,20 +4,26 @@ const { mockExistsSync, mockSelectPiece, mockSelectOptionWithDefault, + mockConfirm, mockResolvePieceConfigValue, mockLoadPieceByIdentifier, mockGetPieceDescription, mockRunRetryMode, mockFindRunForTask, mockFindPreviousOrderContent, + mockLoadRunSessionContext, + mockFormatRunSessionForPrompt, mockStartReExecution, mockRequeueTask, mockExecuteAndCompleteTask, mockWarn, + mockIsPiecePath, + mockLoadAllPiecesWithSources, } = vi.hoisted(() => ({ mockExistsSync: vi.fn(() => true), mockSelectPiece: vi.fn(), mockSelectOptionWithDefault: vi.fn(), + mockConfirm: vi.fn(), mockResolvePieceConfigValue: vi.fn(), mockLoadPieceByIdentifier: vi.fn(), mockGetPieceDescription: vi.fn(() => ({ @@ -29,10 +35,20 @@ const { mockRunRetryMode: vi.fn(), mockFindRunForTask: 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(), mockRequeueTask: vi.fn(), mockExecuteAndCompleteTask: vi.fn(), mockWarn: vi.fn(), + mockIsPiecePath: vi.fn(() => false), + mockLoadAllPiecesWithSources: vi.fn(() => new Map([['default', {}]])), })); vi.mock('node:fs', async (importOriginal) => ({ @@ -46,6 +62,7 @@ vi.mock('../features/pieceSelection/index.js', () => ({ vi.mock('../shared/prompt/index.js', () => ({ selectOptionWithDefault: (...args: unknown[]) => mockSelectOptionWithDefault(...args), + confirm: (...args: unknown[]) => mockConfirm(...args), })); vi.mock('../shared/ui/index.js', () => ({ @@ -68,19 +85,15 @@ vi.mock('../infra/config/index.js', () => ({ resolvePieceConfigValue: (...args: unknown[]) => mockResolvePieceConfigValue(...args), loadPieceByIdentifier: (...args: unknown[]) => mockLoadPieceByIdentifier(...args), getPieceDescription: (...args: unknown[]) => mockGetPieceDescription(...args), + isPiecePath: (...args: unknown[]) => mockIsPiecePath(...args), + loadAllPiecesWithSources: (...args: unknown[]) => mockLoadAllPiecesWithSources(...args), })); vi.mock('../features/interactive/index.js', () => ({ findRunForTask: (...args: unknown[]) => mockFindRunForTask(...args), - loadRunSessionContext: vi.fn(), + loadRunSessionContext: (...args: unknown[]) => mockLoadRunSessionContext(...args), getRunPaths: vi.fn(() => ({ logsDir: '/tmp/logs', reportsDir: '/tmp/reports' })), - formatRunSessionForPrompt: vi.fn(() => ({ - runTask: '', - runPiece: 'default', - runStatus: '', - runMovementLogs: '', - runReports: '', - })), + formatRunSessionForPrompt: (...args: unknown[]) => mockFormatRunSessionForPrompt(...args), runRetryMode: (...args: unknown[]) => mockRunRetryMode(...args), findPreviousOrderContent: (...args: unknown[]) => mockFindPreviousOrderContent(...args), })); @@ -101,13 +114,11 @@ vi.mock('../features/tasks/execute/taskExecution.js', () => ({ })); vi.mock('../shared/i18n/index.js', () => ({ - getLabel: vi.fn((key: string) => { - const labels: Record = { - 'retry.workflowPrompt': 'Select workflow:', - 'retry.usePreviousWorkflow': 'Use previous', - 'retry.changeWorkflow': 'Change workflow', - }; - return labels[key] ?? key; + getLabel: vi.fn((key: string, _lang?: string, vars?: Record) => { + if (vars?.piece) { + return `Use previous piece "${vars.piece}"?`; + } + return key; }), })); @@ -146,12 +157,22 @@ beforeEach(() => { vi.clearAllMocks(); mockExistsSync.mockReturnValue(true); + mockConfirm.mockResolvedValue(true); mockSelectPiece.mockResolvedValue('default'); mockResolvePieceConfigValue.mockReturnValue(3); 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([['default', {}], ['selected-piece', {}]])); mockSelectOptionWithDefault.mockResolvedValue('plan'); mockRunRetryMode.mockResolvedValue({ action: 'execute', task: '追加指示A' }); mockFindPreviousOrderContent.mockReturnValue(null); + mockLoadRunSessionContext.mockReturnValue({ + task: 'Do something', + piece: 'default', + status: 'failed', + movementLogs: [], + reports: [], + }); mockStartReExecution.mockReturnValue({ name: 'my-task', content: 'Do something', @@ -337,29 +358,24 @@ describe('retryFailedTask', () => { expect(mockRequeueTask).toHaveBeenCalledWith('my-task', ['failed'], undefined, '既存ノート\n\n追加指示A'); }); - describe('when previous workflow exists', () => { + describe('when previous piece exists', () => { beforeEach(() => { 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(); await retryFailedTask(task, '/project'); - expect(mockSelectOptionWithDefault).toHaveBeenCalledWith( - 'Select workflow:', - expect.arrayContaining([ - expect.objectContaining({ value: 'use_previous' }), - expect.objectContaining({ value: 'change' }), - ]), - 'use_previous', - ); + const [message, defaultYes] = mockConfirm.mock.calls[0] ?? []; + expect(message).toEqual(expect.stringContaining('"default"')); + expect(defaultYes ?? true).toBe(true); }); - it('should use previous workflow when use_previous is selected', async () => { + it('should use previous piece when reuse is confirmed', async () => { const task = makeFailedTask(); - mockSelectOptionWithDefault.mockResolvedValue('use_previous'); + mockConfirm.mockResolvedValue(true); await retryFailedTask(task, '/project'); @@ -367,23 +383,41 @@ describe('retryFailedTask', () => { 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(); - mockSelectOptionWithDefault.mockResolvedValue('change'); + mockConfirm.mockResolvedValue(false); await retryFailedTask(task, '/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(); - mockSelectOptionWithDefault.mockResolvedValue(null); + mockConfirm.mockResolvedValue(false); + mockSelectPiece.mockResolvedValue(null); const result = await retryFailedTask(task, '/project'); expect(result).toBe(false); 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'); + }); }); }); diff --git a/src/features/tasks/list/requeueHelpers.ts b/src/features/tasks/list/requeueHelpers.ts index fad9074..51f80ed 100644 --- a/src/features/tasks/list/requeueHelpers.ts +++ b/src/features/tasks/list/requeueHelpers.ts @@ -1,6 +1,8 @@ import { confirm } from '../../../shared/prompt/index.js'; import { getLabel } from '../../../shared/i18n/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 { selectRun, @@ -24,6 +26,42 @@ export function appendRetryNote(existing: string | undefined, additional: string 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 { + 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[] { const blockPattern = /```(?:yaml|yml)\s*\n([\s\S]*?)```/gi; const candidates: string[] = []; diff --git a/src/features/tasks/list/taskInstructionActions.ts b/src/features/tasks/list/taskInstructionActions.ts index 820c7ff..3824d02 100644 --- a/src/features/tasks/list/taskInstructionActions.ts +++ b/src/features/tasks/list/taskInstructionActions.ts @@ -15,15 +15,15 @@ import { resolvePieceConfigValues, getPieceDescription } from '../../../infra/co import { info, warn, error as logError } from '../../../shared/ui/index.js'; import { createLogger, getErrorMessage } from '../../../shared/utils/index.js'; import { runInstructMode } from './instructMode.js'; -import { selectPiece } from '../../pieceSelection/index.js'; import { dispatchConversationAction } from '../../interactive/actionDispatcher.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 { appendRetryNote, DEPRECATED_PROVIDER_CONFIG_WARNING, hasDeprecatedProviderConfig, + selectPieceWithOptionalReuse, selectRunSessionContext, } from './requeueHelpers.js'; import { executeAndCompleteTask } from '../execute/taskExecution.js'; @@ -93,13 +93,18 @@ export async function instructBranch( 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) { info('Cancelled'); return false; } - const globalConfig = resolvePieceConfigValues(projectDir, ['interactivePreviewMovements', 'language']); const pieceDesc = getPieceDescription(selectedPiece, projectDir, globalConfig.interactivePreviewMovements); const pieceContext: PieceContext = { name: pieceDesc.name, @@ -108,10 +113,8 @@ export async function instructBranch( movementPreviews: pieceDesc.movementPreviews, }; - const lang = resolveLanguage(globalConfig.language); // Runs data lives in the worktree (written during previous execution) const runSessionContext = await selectRunSessionContext(worktreePath, lang); - const matchedSlug = findRunForTask(worktreePath, target.content); const previousOrderContent = findPreviousOrderContent(worktreePath, matchedSlug); if (hasDeprecatedProviderConfig(previousOrderContent)) { warn(DEPRECATED_PROVIDER_CONFIG_WARNING); diff --git a/src/features/tasks/list/taskRetryActions.ts b/src/features/tasks/list/taskRetryActions.ts index b385d6f..3ce491b 100644 --- a/src/features/tasks/list/taskRetryActions.ts +++ b/src/features/tasks/list/taskRetryActions.ts @@ -9,9 +9,7 @@ import * as fs from 'node:fs'; import type { TaskListItem } from '../../../infra/task/index.js'; import { TaskRunner } from '../../../infra/task/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 { getLabel } from '../../../shared/i18n/index.js'; import { info, header, blankLine, status, warn } from '../../../shared/ui/index.js'; import { createLogger } from '../../../shared/utils/index.js'; import type { PieceConfig } from '../../../core/models/index.js'; @@ -31,6 +29,7 @@ import { appendRetryNote, DEPRECATED_PROVIDER_CONFIG_WARNING, hasDeprecatedProviderConfig, + selectPieceWithOptionalReuse, } from './requeueHelpers.js'; import { prepareTaskForExecution } from './prepareTaskForExecution.js'; @@ -137,41 +136,10 @@ export async function retryFailedTask( const matchedSlug = findRunForTask(worktreePath, task.content); const runInfo = matchedSlug ? buildRetryRunInfo(worktreePath, matchedSlug) : null; - let selectedPiece: string; - if (runInfo?.piece) { - const usePreviousLabel = getLabel('retry.usePreviousWorkflow'); - const changeWorkflowLabel = getLabel('retry.changeWorkflow'); - 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 selectedPiece = await selectPieceWithOptionalReuse(projectDir, runInfo?.piece); + if (!selectedPiece) { + info('Cancelled'); + return false; } const previewCount = resolvePieceConfigValue(projectDir, 'interactivePreviewMovements'); diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index 2b7ea5f..557691c 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -99,9 +99,7 @@ instruct: retry: ui: intro: "Retry mode - describe additional instructions. Commands: /go (create instruction & run), /retry (rerun previous order), /cancel (exit)" - workflowPrompt: "Select workflow:" - usePreviousWorkflow: "Use previous" - changeWorkflow: "Change workflow" + usePreviousPieceConfirm: "Use previous piece \"{piece}\"?" run: notifyComplete: "Run complete ({total} tasks)" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index ff44e5d..332f5ae 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -99,9 +99,7 @@ instruct: retry: ui: intro: "リトライモード - 追加指示を入力してください。コマンド: /go(指示書作成・実行), /retry(前回の指示書で再実行), /cancel(終了)" - workflowPrompt: "ワークフローを選択:" - usePreviousWorkflow: "前回のまま使用" - changeWorkflow: "ワークフローを変更" + usePreviousPieceConfirm: "前回のピース \"{piece}\" を使用しますか?" run: notifyComplete: "run完了 ({total} tasks)"