From 6371b8f3b1acebd36d8a5c6dd1c36b160012ebd9 Mon Sep 17 00:00:00 2001 From: nrs <38722970+nrslib@users.noreply.github.com> Date: Thu, 19 Feb 2026 19:51:18 +0900 Subject: [PATCH] takt: task-1771451707814 (#314) --- src/__tests__/it-retry-mode.test.ts | 5 + .../loadPreviousOrderContent.test.ts | 106 ++++++++++ src/__tests__/retryMode.test.ts | 17 ++ src/__tests__/retrySlashCommand.test.ts | 198 ++++++++++++++++++ src/features/interactive/aiCaller.ts | 123 +++++++++++ src/features/interactive/conversationLoop.ts | 125 ++--------- src/features/interactive/index.ts | 2 +- src/features/interactive/interactive.ts | 1 + src/features/interactive/retryMode.ts | 2 + src/features/interactive/runSessionReader.ts | 22 ++ src/features/tasks/list/instructMode.ts | 2 + src/features/tasks/list/taskRetryActions.ts | 1 + src/shared/i18n/labels_en.yaml | 5 +- src/shared/i18n/labels_ja.yaml | 5 +- 14 files changed, 499 insertions(+), 115 deletions(-) create mode 100644 src/__tests__/loadPreviousOrderContent.test.ts create mode 100644 src/__tests__/retrySlashCommand.test.ts create mode 100644 src/features/interactive/aiCaller.ts diff --git a/src/__tests__/it-retry-mode.test.ts b/src/__tests__/it-retry-mode.test.ts index df5847b..87f6b0f 100644 --- a/src/__tests__/it-retry-mode.test.ts +++ b/src/__tests__/it-retry-mode.test.ts @@ -206,6 +206,7 @@ describe('E2E: Retry mode with failure context injection', () => { movementPreviews: [], }, run: null, + previousOrderContent: null, }; const result = await runRetryMode(tmpDir, retryContext, null); @@ -276,6 +277,7 @@ describe('E2E: Retry mode with failure context injection', () => { movementLogs: formatted.runMovementLogs, reports: formatted.runReports, }, + previousOrderContent: null, }; const result = await runRetryMode(tmpDir, retryContext, null); @@ -331,6 +333,7 @@ describe('E2E: Retry mode with failure context injection', () => { movementPreviews: [], }, run: null, + previousOrderContent: null, }; await runRetryMode(tmpDir, retryContext, null); @@ -366,6 +369,7 @@ describe('E2E: Retry mode with failure context injection', () => { movementPreviews: [], }, run: null, + previousOrderContent: null, }; const result = await runRetryMode(tmpDir, retryContext, null); @@ -404,6 +408,7 @@ describe('E2E: Retry mode with failure context injection', () => { movementPreviews: [], }, run: null, + previousOrderContent: null, }; const result = await runRetryMode(tmpDir, retryContext, null); diff --git a/src/__tests__/loadPreviousOrderContent.test.ts b/src/__tests__/loadPreviousOrderContent.test.ts new file mode 100644 index 0000000..be72890 --- /dev/null +++ b/src/__tests__/loadPreviousOrderContent.test.ts @@ -0,0 +1,106 @@ +/** + * Tests for loadPreviousOrderContent utility function. + * + * Verifies order.md loading from run directories, + * including happy path, missing slug, and missing file cases. + */ + +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { loadPreviousOrderContent } from '../features/interactive/runSessionReader.js'; + +function createTmpDir(): string { + const dir = join(tmpdir(), `takt-order-test-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function createRunWithOrder(cwd: string, slug: string, taskContent: string, orderContent: string): void { + const runDir = join(cwd, '.takt', 'runs', slug); + mkdirSync(join(runDir, 'context', 'task'), { recursive: true }); + + const meta = { + task: taskContent, + piece: 'default', + status: 'completed', + startTime: '2026-02-01T00:00:00.000Z', + logsDirectory: `.takt/runs/${slug}/logs`, + reportDirectory: `.takt/runs/${slug}/reports`, + runSlug: slug, + }; + writeFileSync(join(runDir, 'meta.json'), JSON.stringify(meta), 'utf-8'); + writeFileSync(join(runDir, 'context', 'task', 'order.md'), orderContent, 'utf-8'); +} + +function createRunWithoutOrder(cwd: string, slug: string, taskContent: string): void { + const runDir = join(cwd, '.takt', 'runs', slug); + mkdirSync(runDir, { recursive: true }); + + const meta = { + task: taskContent, + piece: 'default', + status: 'completed', + startTime: '2026-02-01T00:00:00.000Z', + logsDirectory: `.takt/runs/${slug}/logs`, + reportDirectory: `.takt/runs/${slug}/reports`, + runSlug: slug, + }; + writeFileSync(join(runDir, 'meta.json'), JSON.stringify(meta), 'utf-8'); +} + +describe('loadPreviousOrderContent', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTmpDir(); + }); + + afterEach(() => { + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should return order.md content when run and file exist', () => { + const taskContent = 'Implement feature X'; + const orderContent = '# Task\n\nImplement feature X with tests.'; + createRunWithOrder(tmpDir, 'run-feature-x', taskContent, orderContent); + + const result = loadPreviousOrderContent(tmpDir, taskContent); + + expect(result).toBe(orderContent); + }); + + it('should return null when no matching run exists', () => { + const result = loadPreviousOrderContent(tmpDir, 'Non-existent task'); + + expect(result).toBeNull(); + }); + + it('should return null when run exists but order.md is missing', () => { + const taskContent = 'Task without order'; + createRunWithoutOrder(tmpDir, 'run-no-order', taskContent); + + const result = loadPreviousOrderContent(tmpDir, taskContent); + + expect(result).toBeNull(); + }); + + it('should return null when .takt/runs directory does not exist', () => { + const emptyDir = join(tmpdir(), `takt-empty-${Date.now()}`); + mkdirSync(emptyDir, { recursive: true }); + + const result = loadPreviousOrderContent(emptyDir, 'any task'); + + expect(result).toBeNull(); + rmSync(emptyDir, { recursive: true, force: true }); + }); + + it('should match the correct run among multiple runs', () => { + createRunWithOrder(tmpDir, 'run-a', 'Task A', '# Order A'); + createRunWithOrder(tmpDir, 'run-b', 'Task B', '# Order B'); + + expect(loadPreviousOrderContent(tmpDir, 'Task A')).toBe('# Order A'); + expect(loadPreviousOrderContent(tmpDir, 'Task B')).toBe('# Order B'); + }); +}); diff --git a/src/__tests__/retryMode.test.ts b/src/__tests__/retryMode.test.ts index cb3261b..21cb927 100644 --- a/src/__tests__/retryMode.test.ts +++ b/src/__tests__/retryMode.test.ts @@ -24,6 +24,7 @@ function createRetryContext(overrides?: Partial): RetryContext { movementPreviews: [], }, run: null, + previousOrderContent: null, ...overrides, }; } @@ -131,6 +132,22 @@ describe('buildRetryTemplateVars', () => { expect(vars.movementDetails).toContain('Architect'); }); + it('should set hasPreviousOrder=false and empty previousOrderContent when previousOrderContent is null', () => { + const ctx = createRetryContext({ previousOrderContent: null }); + const vars = buildRetryTemplateVars(ctx, 'en'); + + expect(vars.hasPreviousOrder).toBe(false); + expect(vars.previousOrderContent).toBe(''); + }); + + it('should set hasPreviousOrder=true and populate previousOrderContent when provided', () => { + const ctx = createRetryContext({ previousOrderContent: '# Order content' }); + const vars = buildRetryTemplateVars(ctx, 'en'); + + expect(vars.hasPreviousOrder).toBe(true); + expect(vars.previousOrderContent).toBe('# Order content'); + }); + it('should include retryNote when present', () => { const ctx = createRetryContext({ failure: { diff --git a/src/__tests__/retrySlashCommand.test.ts b/src/__tests__/retrySlashCommand.test.ts new file mode 100644 index 0000000..5f36ac2 --- /dev/null +++ b/src/__tests__/retrySlashCommand.test.ts @@ -0,0 +1,198 @@ +/** + * Tests for /retry slash command in the conversation loop. + * + * Verifies: + * - /retry with previousOrderContent returns execute action with order content + * - /retry without previousOrderContent shows error and continues loop + * - /retry in retry mode with order.md context in system prompt + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { mkdirSync, writeFileSync, rmSync } from 'node:fs'; +import { join } from 'node:path'; +import { tmpdir } from 'node:os'; +import { + setupRawStdin, + restoreStdin, + toRawInputs, + createMockProvider, + type MockProviderCapture, +} from './helpers/stdinSimulator.js'; + +// --- Mocks (infrastructure only) --- + +vi.mock('../infra/fs/session.js', () => ({ + loadNdjsonLog: vi.fn(), +})); + +vi.mock('../infra/config/global/globalConfig.js', () => ({ + loadGlobalConfig: vi.fn(() => ({ provider: 'mock', language: 'en' })), + getBuiltinPiecesEnabled: vi.fn().mockReturnValue(true), +})); + +vi.mock('../infra/providers/index.js', () => ({ + getProvider: vi.fn(), +})); + +vi.mock('../shared/utils/index.js', async (importOriginal) => ({ + ...(await importOriginal>()), + createLogger: () => ({ + info: vi.fn(), + debug: vi.fn(), + error: vi.fn(), + }), +})); + +vi.mock('../shared/context.js', () => ({ + isQuietMode: vi.fn(() => false), +})); + +vi.mock('../infra/config/paths.js', async (importOriginal) => ({ + ...(await importOriginal>()), + loadPersonaSessions: vi.fn(() => ({})), + updatePersonaSession: vi.fn(), + getProjectConfigDir: vi.fn(() => '/tmp'), + loadSessionState: vi.fn(() => null), + clearSessionState: vi.fn(), +})); + +vi.mock('../shared/ui/index.js', () => ({ + info: vi.fn(), + error: vi.fn(), + blankLine: vi.fn(), + StreamDisplay: vi.fn().mockImplementation(() => ({ + createHandler: vi.fn(() => vi.fn()), + flush: vi.fn(), + })), +})); + +vi.mock('../shared/prompt/index.js', () => ({ + selectOption: vi.fn().mockResolvedValue('execute'), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn((_key: string, _lang: string) => 'Mock label'), + getLabelObject: vi.fn(() => ({ + intro: 'Retry intro', + resume: 'Resume', + noConversation: 'No conversation', + summarizeFailed: 'Summarize failed', + continuePrompt: 'Continue?', + proposed: 'Proposed:', + actionPrompt: 'What next?', + playNoTask: 'No task', + cancelled: 'Cancelled', + retryNoOrder: 'No previous order found.', + actions: { execute: 'Execute', saveTask: 'Save', continue: 'Continue' }, + })), +})); + +// --- Imports (after mocks) --- + +import { getProvider } from '../infra/providers/index.js'; +import { runRetryMode, type RetryContext } from '../features/interactive/retryMode.js'; +import { info } from '../shared/ui/index.js'; + +const mockGetProvider = vi.mocked(getProvider); +const mockInfo = vi.mocked(info); + +function createTmpDir(): string { + const dir = join(tmpdir(), `takt-retry-cmd-${Date.now()}-${Math.random().toString(36).slice(2)}`); + mkdirSync(dir, { recursive: true }); + return dir; +} + +function setupProvider(responses: string[]): MockProviderCapture { + const { provider, capture } = createMockProvider(responses); + mockGetProvider.mockReturnValue(provider); + return capture; +} + +function buildRetryContext(overrides?: Partial): RetryContext { + return { + failure: { + taskName: 'test-task', + taskContent: 'Test task content', + createdAt: '2026-02-15T10:00:00Z', + failedMovement: 'implement', + error: 'Some error', + lastMessage: '', + retryNote: '', + }, + branchName: 'takt/test-task', + pieceContext: { + name: 'default', + description: '', + pieceStructure: '', + movementPreviews: [], + }, + run: null, + previousOrderContent: null, + ...overrides, + }; +} + +// --- Tests --- + +describe('/retry slash command', () => { + let tmpDir: string; + + beforeEach(() => { + tmpDir = createTmpDir(); + vi.clearAllMocks(); + }); + + afterEach(() => { + restoreStdin(); + rmSync(tmpDir, { recursive: true, force: true }); + }); + + it('should execute with previous order content when /retry is used', async () => { + const orderContent = '# Task Order\n\nImplement feature X with tests.'; + setupRawStdin(toRawInputs(['/retry'])); + setupProvider([]); + + const retryContext = buildRetryContext({ previousOrderContent: orderContent }); + const result = await runRetryMode(tmpDir, retryContext); + + expect(result.action).toBe('execute'); + expect(result.task).toBe(orderContent); + }); + + it('should show error and continue when /retry is used without order', async () => { + setupRawStdin(toRawInputs(['/retry', '/cancel'])); + setupProvider([]); + + const retryContext = buildRetryContext({ previousOrderContent: null }); + const result = await runRetryMode(tmpDir, retryContext); + + expect(mockInfo).toHaveBeenCalledWith('No previous order found.'); + expect(result.action).toBe('cancel'); + }); + + it('should inject order.md content into retry system prompt', async () => { + const orderContent = '# Build login page\n\nWith OAuth2 support.'; + setupRawStdin(toRawInputs(['check the order', '/cancel'])); + const capture = setupProvider(['I see the order content.']); + + const retryContext = buildRetryContext({ previousOrderContent: orderContent }); + await runRetryMode(tmpDir, retryContext); + + expect(capture.systemPrompts.length).toBeGreaterThan(0); + const systemPrompt = capture.systemPrompts[0]!; + expect(systemPrompt).toContain('Previous Order'); + expect(systemPrompt).toContain(orderContent); + }); + + it('should not include order section when no order content', async () => { + setupRawStdin(toRawInputs(['check the order', '/cancel'])); + const capture = setupProvider(['No order found.']); + + const retryContext = buildRetryContext({ previousOrderContent: null }); + await runRetryMode(tmpDir, retryContext); + + expect(capture.systemPrompts.length).toBeGreaterThan(0); + const systemPrompt = capture.systemPrompts[0]!; + expect(systemPrompt).not.toContain('Previous Order'); + }); +}); diff --git a/src/features/interactive/aiCaller.ts b/src/features/interactive/aiCaller.ts new file mode 100644 index 0000000..a2efebd --- /dev/null +++ b/src/features/interactive/aiCaller.ts @@ -0,0 +1,123 @@ +/** + * AI call with automatic retry on stale/invalid session. + * + * Extracted from conversationLoop.ts for single-responsibility: + * this module handles only the AI call + retry logic. + */ + +import { + updatePersonaSession, +} from '../../infra/config/index.js'; +import { isQuietMode } from '../../shared/context.js'; +import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; +import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js'; +import { getLabel } from '../../shared/i18n/index.js'; +import { EXIT_SIGINT } from '../../shared/exitCodes.js'; +import type { ProviderType } from '../../infra/providers/index.js'; +import { getProvider } from '../../infra/providers/index.js'; + +const log = createLogger('ai-caller'); + +/** Result from a single AI call */ +export interface CallAIResult { + content: string; + sessionId?: string; + success: boolean; +} + +/** Initialized session context for conversation loops */ +export interface SessionContext { + provider: ReturnType; + providerType: ProviderType; + model: string | undefined; + lang: 'en' | 'ja'; + personaName: string; + sessionId: string | undefined; +} + +/** + * Call AI with automatic retry on stale/invalid session. + * + * On session failure, clears sessionId and retries once without session. + * Updates sessionId and persists it on success. + */ +export async function callAIWithRetry( + prompt: string, + systemPrompt: string, + allowedTools: string[], + cwd: string, + ctx: SessionContext, +): Promise<{ result: CallAIResult | null; sessionId: string | undefined }> { + const display = new StreamDisplay('assistant', isQuietMode()); + const abortController = new AbortController(); + let sigintCount = 0; + const onSigInt = (): void => { + sigintCount += 1; + if (sigintCount === 1) { + blankLine(); + info(getLabel('piece.sigintGraceful', ctx.lang)); + abortController.abort(); + return; + } + blankLine(); + error(getLabel('piece.sigintForce', ctx.lang)); + process.exit(EXIT_SIGINT); + }; + process.on('SIGINT', onSigInt); + let { sessionId } = ctx; + + try { + const agent = ctx.provider.setup({ name: ctx.personaName, systemPrompt }); + const response = await agent.call(prompt, { + cwd, + model: ctx.model, + sessionId, + allowedTools, + abortSignal: abortController.signal, + onStream: display.createHandler(), + }); + display.flush(); + const success = response.status !== 'blocked' && response.status !== 'error'; + + if (!success && sessionId) { + log.info('Session invalid, retrying without session'); + sessionId = undefined; + const retryDisplay = new StreamDisplay('assistant', isQuietMode()); + const retryAgent = ctx.provider.setup({ name: ctx.personaName, systemPrompt }); + const retry = await retryAgent.call(prompt, { + cwd, + model: ctx.model, + sessionId: undefined, + allowedTools, + abortSignal: abortController.signal, + onStream: retryDisplay.createHandler(), + }); + retryDisplay.flush(); + if (retry.sessionId) { + sessionId = retry.sessionId; + updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType); + } + return { + result: { content: retry.content, sessionId: retry.sessionId, success: retry.status !== 'blocked' && retry.status !== 'error' }, + sessionId, + }; + } + + if (response.sessionId) { + sessionId = response.sessionId; + updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType); + } + return { + result: { content: response.content, sessionId: response.sessionId, success }, + sessionId, + }; + } catch (e) { + const msg = getErrorMessage(e); + log.error('AI call failed', { error: msg }); + error(msg); + blankLine(); + return { result: null, sessionId }; + } finally { + process.removeListener('SIGINT', onSigInt); + } +} diff --git a/src/features/interactive/conversationLoop.ts b/src/features/interactive/conversationLoop.ts index 1cd99f0..13cc3ba 100644 --- a/src/features/interactive/conversationLoop.ts +++ b/src/features/interactive/conversationLoop.ts @@ -3,7 +3,6 @@ * * Extracts the common patterns: * - Provider/session initialization - * - AI call with retry on stale session * - Session state display/clear * - Conversation loop (slash commands, AI messaging, /go summary) */ @@ -12,14 +11,12 @@ import chalk from 'chalk'; import { resolveConfigValues, loadPersonaSessions, - updatePersonaSession, loadSessionState, clearSessionState, } from '../../infra/config/index.js'; -import { isQuietMode } from '../../shared/context.js'; import { getProvider, type ProviderType } from '../../infra/providers/index.js'; -import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; -import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js'; +import { createLogger } from '../../shared/utils/index.js'; +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'; @@ -35,26 +32,12 @@ import { selectPostSummaryAction, formatSessionStatus, } from './interactive.js'; +import { callAIWithRetry, type CallAIResult, type SessionContext } from './aiCaller.js'; + +export { type CallAIResult, type SessionContext, callAIWithRetry } from './aiCaller.js'; const log = createLogger('conversation-loop'); -/** Result from a single AI call */ -export interface CallAIResult { - content: string; - sessionId?: string; - success: boolean; -} - -/** Initialized session context for conversation loops */ -export interface SessionContext { - provider: ReturnType; - providerType: ProviderType; - model: string | undefined; - lang: 'en' | 'ja'; - personaName: string; - sessionId: string | undefined; -} - /** * Initialize provider and language for interactive conversation. * @@ -88,93 +71,6 @@ export function displayAndClearSessionState(cwd: string, lang: 'en' | 'ja'): voi } } -/** - * Call AI with automatic retry on stale/invalid session. - * - * On session failure, clears sessionId and retries once without session. - * Updates sessionId and persists it on success. - */ -export async function callAIWithRetry( - prompt: string, - systemPrompt: string, - allowedTools: string[], - cwd: string, - ctx: SessionContext, -): Promise<{ result: CallAIResult | null; sessionId: string | undefined }> { - const display = new StreamDisplay('assistant', isQuietMode()); - const abortController = new AbortController(); - let sigintCount = 0; - const onSigInt = (): void => { - sigintCount += 1; - if (sigintCount === 1) { - blankLine(); - info(getLabel('piece.sigintGraceful', ctx.lang)); - abortController.abort(); - return; - } - blankLine(); - error(getLabel('piece.sigintForce', ctx.lang)); - process.exit(EXIT_SIGINT); - }; - process.on('SIGINT', onSigInt); - let { sessionId } = ctx; - - try { - const agent = ctx.provider.setup({ name: ctx.personaName, systemPrompt }); - const response = await agent.call(prompt, { - cwd, - model: ctx.model, - sessionId, - allowedTools, - abortSignal: abortController.signal, - onStream: display.createHandler(), - }); - display.flush(); - const success = response.status !== 'blocked' && response.status !== 'error'; - - if (!success && sessionId) { - log.info('Session invalid, retrying without session'); - sessionId = undefined; - const retryDisplay = new StreamDisplay('assistant', isQuietMode()); - const retryAgent = ctx.provider.setup({ name: ctx.personaName, systemPrompt }); - const retry = await retryAgent.call(prompt, { - cwd, - model: ctx.model, - sessionId: undefined, - allowedTools, - abortSignal: abortController.signal, - onStream: retryDisplay.createHandler(), - }); - retryDisplay.flush(); - if (retry.sessionId) { - sessionId = retry.sessionId; - updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType); - } - return { - result: { content: retry.content, sessionId: retry.sessionId, success: retry.status !== 'blocked' && retry.status !== 'error' }, - sessionId, - }; - } - - if (response.sessionId) { - sessionId = response.sessionId; - updatePersonaSession(cwd, ctx.personaName, sessionId, ctx.providerType); - } - return { - result: { content: response.content, sessionId: response.sessionId, success }, - sessionId, - }; - } catch (e) { - const msg = getErrorMessage(e); - log.error('AI call failed', { error: msg }); - error(msg); - blankLine(); - return { result: null, sessionId }; - } finally { - process.removeListener('SIGINT', onSigInt); - } -} - export type { PostSummaryAction } from './interactive.js'; /** Strategy for customizing conversation loop behavior */ @@ -196,7 +92,7 @@ export interface ConversationStrategy { /** * Run the shared conversation loop. * - * Handles: EOF, /play, /go (summary), /cancel, regular AI messaging. + * Handles: EOF, /play, /retry, /go (summary), /cancel, regular AI messaging. * The Strategy object controls system prompt, tool access, and prompt transformation. */ export async function runConversationLoop( @@ -271,6 +167,15 @@ export async function runConversationLoop( return { action: 'execute', task }; } + if (trimmed === '/retry') { + if (!strategy.previousOrderContent) { + info(ui.retryNoOrder); + continue; + } + log.info('Retry command — resubmitting previous order.md'); + return { action: 'execute', task: strategy.previousOrderContent }; + } + if (trimmed.startsWith('/go')) { const userNote = trimmed.slice(3).trim(); let summaryPrompt = buildSummaryPrompt( diff --git a/src/features/interactive/index.ts b/src/features/interactive/index.ts index 0f4b101..bceb902 100644 --- a/src/features/interactive/index.ts +++ b/src/features/interactive/index.ts @@ -22,7 +22,7 @@ export { passthroughMode } from './passthroughMode.js'; export { quietMode } from './quietMode.js'; export { personaMode } from './personaMode.js'; export { selectRun } from './runSelector.js'; -export { listRecentRuns, findRunForTask, loadRunSessionContext, formatRunSessionForPrompt, getRunPaths, type RunSessionContext, type RunPaths } from './runSessionReader.js'; +export { listRecentRuns, findRunForTask, loadRunSessionContext, formatRunSessionForPrompt, getRunPaths, loadPreviousOrderContent, type RunSessionContext, type RunPaths } from './runSessionReader.js'; export { runRetryMode, buildRetryTemplateVars, type RetryContext, type RetryFailureInfo, type RetryRunInfo } from './retryMode.js'; export { dispatchConversationAction, type ConversationActionResult } from './actionDispatcher.js'; export { findPreviousOrderContent } from './orderReader.js'; diff --git a/src/features/interactive/interactive.ts b/src/features/interactive/interactive.ts index 63ef1b1..398ead3 100644 --- a/src/features/interactive/interactive.ts +++ b/src/features/interactive/interactive.ts @@ -45,6 +45,7 @@ export interface InteractiveUIText { }; cancelled: string; playNoTask: string; + retryNoOrder: string; } /** diff --git a/src/features/interactive/retryMode.ts b/src/features/interactive/retryMode.ts index 70b7879..1369a47 100644 --- a/src/features/interactive/retryMode.ts +++ b/src/features/interactive/retryMode.ts @@ -52,6 +52,7 @@ export interface RetryContext { readonly branchName: string; readonly pieceContext: PieceContext; readonly run: RetryRunInfo | null; + readonly previousOrderContent: string | null; } const RETRY_TOOLS = ['Read', 'Glob', 'Grep', 'Bash', 'WebSearch', 'WebFetch']; @@ -66,6 +67,7 @@ export function buildRetryTemplateVars(ctx: RetryContext, lang: 'en' | 'ja', pre : ''; const hasRun = ctx.run !== null; + const hasPreviousOrder = ctx.previousOrderContent !== null; return { taskName: ctx.failure.taskName, diff --git a/src/features/interactive/runSessionReader.ts b/src/features/interactive/runSessionReader.ts index 28e672d..da4b4d1 100644 --- a/src/features/interactive/runSessionReader.ts +++ b/src/features/interactive/runSessionReader.ts @@ -216,6 +216,28 @@ export function loadRunSessionContext(cwd: string, slug: string): RunSessionCont }; } +/** + * Load the previous order.md content from the run directory. + * + * Uses findRunForTask to locate the matching run by task content, + * then reads order.md from its context/task directory. + * + * @returns The order.md content if found, null otherwise. + */ +export function loadPreviousOrderContent(cwd: string, taskContent: string): string | null { + const slug = findRunForTask(cwd, taskContent); + if (!slug) { + return null; + } + + const orderPath = join(cwd, '.takt', 'runs', slug, 'context', 'task', 'order.md'); + if (!existsSync(orderPath)) { + return null; + } + + return readFileSync(orderPath, 'utf-8'); +} + /** * Format run session context into a text block for the system prompt. */ diff --git a/src/features/tasks/list/instructMode.ts b/src/features/tasks/list/instructMode.ts index c6838b0..d717253 100644 --- a/src/features/tasks/list/instructMode.ts +++ b/src/features/tasks/list/instructMode.ts @@ -69,6 +69,8 @@ function buildInstructTemplateVars( ? formatRunSessionForPrompt(runSessionContext) : { runTask: '', runPiece: '', runStatus: '', runMovementLogs: '', runReports: '' }; + const hasPreviousOrder = !!previousOrderContent; + return { taskName, taskContent, diff --git a/src/features/tasks/list/taskRetryActions.ts b/src/features/tasks/list/taskRetryActions.ts index 80c29f4..57759d0 100644 --- a/src/features/tasks/list/taskRetryActions.ts +++ b/src/features/tasks/list/taskRetryActions.ts @@ -166,6 +166,7 @@ export async function retryFailedTask( branchName, pieceContext, run: runInfo, + previousOrderContent, }; const retryResult = await runRetryMode(worktreePath, retryContext, previousOrderContent); diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index 421596a..31167cc 100644 --- a/src/shared/i18n/labels_en.yaml +++ b/src/shared/i18n/labels_en.yaml @@ -10,7 +10,7 @@ interactive: conversationLabel: "Conversation:" noTranscript: "(No local transcript. Summarize the current session context.)" ui: - intro: "Interactive mode - describe your task. Commands: /go (execute), /play (run now), /resume (load session), /cancel (exit)" + intro: "Interactive mode - describe your task. Commands: /go (execute), /play (run now), /resume (load session), /retry (rerun previous order), /cancel (exit)" resume: "Resuming previous session" noConversation: "No conversation yet. Please describe your task first." summarizeFailed: "Failed to summarize conversation. Please try again." @@ -24,6 +24,7 @@ interactive: continue: "Continue editing" cancelled: "Cancelled" playNoTask: "Please specify task content: /play " + retryNoOrder: "No previous order (order.md) found. /retry is only available during retry." personaFallback: "No persona available for the first movement. Falling back to assistant mode." modeSelection: prompt: "Select interactive mode:" @@ -76,7 +77,7 @@ piece: # ===== Instruct Mode UI (takt list -> instruct) ===== instruct: ui: - intro: "Instruct mode - describe additional instructions. Commands: /go (summarize), /cancel (exit)" + intro: "Instruct mode - describe additional instructions. Commands: /go (summarize), /retry (rerun previous order), /cancel (exit)" resume: "Resuming previous session" noConversation: "No conversation yet. Please describe your instructions first." summarizeFailed: "Failed to summarize conversation. Please try again." diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index 8dac491..bf0353e 100644 --- a/src/shared/i18n/labels_ja.yaml +++ b/src/shared/i18n/labels_ja.yaml @@ -10,7 +10,7 @@ interactive: conversationLabel: "会話:" noTranscript: "(ローカル履歴なし。現在のセッション文脈を要約してください。)" ui: - intro: "対話モード - タスク内容を入力してください。コマンド: /go(実行), /play(即実行), /resume(セッション読込), /cancel(終了)" + intro: "対話モード - タスク内容を入力してください。コマンド: /go(実行), /play(即実行), /resume(セッション読込), /retry(前回の指示書で再実行), /cancel(終了)" resume: "前回のセッションを再開します" noConversation: "まだ会話がありません。まずタスク内容を入力してください。" summarizeFailed: "会話の要約に失敗しました。再度お試しください。" @@ -24,6 +24,7 @@ interactive: continue: "会話を続ける" cancelled: "キャンセルしました" playNoTask: "タスク内容を指定してください: /play <タスク内容>" + retryNoOrder: "前回の指示書(order.md)が見つかりません。/retry はリトライ時のみ使用できます。" personaFallback: "先頭ムーブメントにペルソナがありません。アシスタントモードにフォールバックします。" modeSelection: prompt: "対話モードを選択してください:" @@ -76,7 +77,7 @@ piece: # ===== Instruct Mode UI (takt list -> instruct) ===== instruct: ui: - intro: "指示モード - 追加指示を入力してください。コマンド: /go(要約), /cancel(終了)" + intro: "指示モード - 追加指示を入力してください。コマンド: /go(要約), /retry(前回の指示書で再実行), /cancel(終了)" resume: "前回のセッションを再開します" noConversation: "まだ会話がありません。まず追加指示を入力してください。" summarizeFailed: "会話の要約に失敗しました。再度お試しください。"