diff --git a/src/__tests__/cli-routing-issue-resolve.test.ts b/src/__tests__/cli-routing-issue-resolve.test.ts index 9e3875c..7eed4f1 100644 --- a/src/__tests__/cli-routing-issue-resolve.test.ts +++ b/src/__tests__/cli-routing-issue-resolve.test.ts @@ -15,7 +15,6 @@ vi.mock('../shared/ui/index.js', () => ({ })); vi.mock('../shared/prompt/index.js', () => ({ - confirm: vi.fn(() => true), })); vi.mock('../shared/utils/index.js', async (importOriginal) => ({ @@ -51,7 +50,6 @@ vi.mock('../features/pipeline/index.js', () => ({ vi.mock('../features/interactive/index.js', () => ({ interactiveMode: vi.fn(), selectInteractiveMode: vi.fn(() => 'assistant'), - selectRecentSession: vi.fn(() => null), passthroughMode: vi.fn(), quietMode: vi.fn(), personaMode: vi.fn(), @@ -78,6 +76,7 @@ vi.mock('../infra/config/index.js', () => ({ getPieceDescription: vi.fn(() => ({ name: 'default', description: 'test piece', pieceStructure: '', movementPreviews: [] })), resolveConfigValue: vi.fn((_: string, key: string) => (key === 'piece' ? 'default' : false)), resolveConfigValues: vi.fn(() => ({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' })), + loadPersonaSessions: vi.fn(() => ({})), })); vi.mock('../shared/constants.js', () => ({ @@ -107,11 +106,11 @@ vi.mock('../app/cli/helpers.js', () => ({ import { checkGhCli, fetchIssue, formatIssueAsTask, parseIssueNumbers } from '../infra/github/issue.js'; import { selectAndExecuteTask, determinePiece, createIssueFromTask, saveTaskFromInteractive } from '../features/tasks/index.js'; -import { interactiveMode, selectRecentSession } from '../features/interactive/index.js'; -import { resolveConfigValues } from '../infra/config/index.js'; -import { confirm } from '../shared/prompt/index.js'; +import { interactiveMode } from '../features/interactive/index.js'; +import { resolveConfigValues, loadPersonaSessions } from '../infra/config/index.js'; import { isDirectTask } from '../app/cli/helpers.js'; import { executeDefaultAction } from '../app/cli/routing.js'; +import { info } from '../shared/ui/index.js'; import type { GitHubIssue } from '../infra/github/types.js'; const mockCheckGhCli = vi.mocked(checkGhCli); @@ -123,10 +122,10 @@ const mockDeterminePiece = vi.mocked(determinePiece); const mockCreateIssueFromTask = vi.mocked(createIssueFromTask); const mockSaveTaskFromInteractive = vi.mocked(saveTaskFromInteractive); const mockInteractiveMode = vi.mocked(interactiveMode); -const mockSelectRecentSession = vi.mocked(selectRecentSession); +const mockLoadPersonaSessions = vi.mocked(loadPersonaSessions); const mockResolveConfigValues = vi.mocked(resolveConfigValues); -const mockConfirm = vi.mocked(confirm); const mockIsDirectTask = vi.mocked(isDirectTask); +const mockInfo = vi.mocked(info); const mockTaskRunnerListAllTaskItems = vi.mocked(mockListAllTaskItems); function createMockIssue(number: number): GitHubIssue { @@ -148,7 +147,6 @@ beforeEach(() => { // Default setup mockDeterminePiece.mockResolvedValue('default'); mockInteractiveMode.mockResolvedValue({ action: 'execute', task: 'summarized task' }); - mockConfirm.mockResolvedValue(true); mockIsDirectTask.mockReturnValue(false); mockParseIssueNumbers.mockReturnValue([]); mockTaskRunnerListAllTaskItems.mockReturnValue([]); @@ -481,41 +479,43 @@ describe('Issue resolution in routing', () => { }); }); - describe('session selection with provider=claude', () => { - it('should pass selected session ID to interactiveMode when provider is claude', async () => { + describe('--continue option', () => { + it('should load saved session and pass to interactiveMode when --continue is specified', async () => { // Given + mockOpts.continue = true; mockResolveConfigValues.mockReturnValue({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' }); - mockConfirm.mockResolvedValue(true); - mockSelectRecentSession.mockResolvedValue('session-xyz'); + mockLoadPersonaSessions.mockReturnValue({ interactive: 'saved-session-123' }); // When await executeDefaultAction(); - // Then: selectRecentSession should be called - expect(mockSelectRecentSession).toHaveBeenCalledWith('/test/cwd', 'en'); + // Then: loadPersonaSessions should be called with provider + expect(mockLoadPersonaSessions).toHaveBeenCalledWith('/test/cwd', 'claude'); - // Then: interactiveMode should receive the session ID as 4th argument + // Then: interactiveMode should receive the saved session ID expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', undefined, expect.anything(), - 'session-xyz', + 'saved-session-123', ); - - expect(mockConfirm).toHaveBeenCalledWith('Choose a previous session?', false); }); - it('should not call selectRecentSession when user selects no in confirmation', async () => { + it('should show message and start new session when --continue has no saved session', async () => { // Given + mockOpts.continue = true; mockResolveConfigValues.mockReturnValue({ language: 'en', interactivePreviewMovements: 3, provider: 'claude' }); - mockConfirm.mockResolvedValue(false); + mockLoadPersonaSessions.mockReturnValue({}); // When await executeDefaultAction(); - // Then - expect(mockConfirm).toHaveBeenCalledWith('Choose a previous session?', false); - expect(mockSelectRecentSession).not.toHaveBeenCalled(); + // Then: info message about no session + expect(mockInfo).toHaveBeenCalledWith( + 'No previous assistant session found. Starting a new session.', + ); + + // Then: interactiveMode should be called with undefined session ID expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', undefined, @@ -524,15 +524,12 @@ describe('Issue resolution in routing', () => { ); }); - it('should not call selectRecentSession when provider is not claude', async () => { - // Given - mockResolveConfigValues.mockReturnValue({ language: 'en', interactivePreviewMovements: 3, provider: 'openai' }); - + it('should not load persona sessions when --continue is not specified', async () => { // When await executeDefaultAction(); - // Then: selectRecentSession should NOT be called - expect(mockSelectRecentSession).not.toHaveBeenCalled(); + // Then: loadPersonaSessions should NOT be called + expect(mockLoadPersonaSessions).not.toHaveBeenCalled(); // Then: interactiveMode should be called with undefined session ID expect(mockInteractiveMode).toHaveBeenCalledWith( @@ -544,14 +541,11 @@ describe('Issue resolution in routing', () => { }); }); - describe('run session reference', () => { - it('should not prompt run session reference in default interactive flow', async () => { + describe('default assistant mode (no --continue)', () => { + it('should start new session without loading saved sessions', async () => { await executeDefaultAction(); - expect(mockConfirm).not.toHaveBeenCalledWith( - "Reference a previous run's results?", - false, - ); + expect(mockLoadPersonaSessions).not.toHaveBeenCalled(); expect(mockInteractiveMode).toHaveBeenCalledWith( '/test/cwd', undefined, diff --git a/src/__tests__/conversationLoop-resume.test.ts b/src/__tests__/conversationLoop-resume.test.ts new file mode 100644 index 0000000..381d2c7 --- /dev/null +++ b/src/__tests__/conversationLoop-resume.test.ts @@ -0,0 +1,215 @@ +/** + * Tests for /resume command and initializeSession changes. + * + * Verifies: + * - initializeSession returns sessionId: undefined (no implicit auto-load) + * - /resume command calls selectRecentSession and updates sessionId + * - /resume with cancel does not change sessionId + */ + +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { + setupRawStdin, + restoreStdin, + toRawInputs, + createMockProvider, + createScenarioProvider, + type MockProviderCapture, +} from './helpers/stdinSimulator.js'; + +// --- Infrastructure mocks --- + +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'), +})); + +const mockSelectRecentSession = vi.fn<(cwd: string, lang: 'en' | 'ja') => Promise>(); + +vi.mock('../features/interactive/sessionSelector.js', () => ({ + selectRecentSession: (...args: [string, 'en' | 'ja']) => mockSelectRecentSession(...args), +})); + +vi.mock('../shared/i18n/index.js', () => ({ + getLabel: vi.fn((_key: string, _lang: string) => 'Mock label'), + getLabelObject: vi.fn(() => ({ + intro: 'Intro', + resume: 'Resume', + noConversation: 'No conversation', + summarizeFailed: 'Summarize failed', + continuePrompt: 'Continue?', + proposed: 'Proposed:', + actionPrompt: 'What next?', + playNoTask: 'No task for /play', + cancelled: 'Cancelled', + actions: { execute: 'Execute', saveTask: 'Save', continue: 'Continue' }, + })), +})); + +// --- Imports (after mocks) --- + +import { getProvider } from '../infra/providers/index.js'; +import { selectOption } from '../shared/prompt/index.js'; +import { info as logInfo } from '../shared/ui/index.js'; +import { initializeSession, runConversationLoop, type SessionContext } from '../features/interactive/conversationLoop.js'; + +const mockGetProvider = vi.mocked(getProvider); +const mockSelectOption = vi.mocked(selectOption); +const mockLogInfo = vi.mocked(logInfo); + +// --- Helpers --- + +function setupProvider(responses: string[]): MockProviderCapture { + const { provider, capture } = createMockProvider(responses); + mockGetProvider.mockReturnValue(provider); + return capture; +} + +function createSessionContext(overrides: Partial = {}): SessionContext { + const { provider } = createMockProvider([]); + mockGetProvider.mockReturnValue(provider); + return { + provider: provider as SessionContext['provider'], + providerType: 'mock' as SessionContext['providerType'], + model: undefined, + lang: 'en', + personaName: 'interactive', + sessionId: undefined, + ...overrides, + }; +} + +const defaultStrategy = { + systemPrompt: 'test system prompt', + allowedTools: ['Read'], + transformPrompt: (msg: string) => msg, + introMessage: 'Test intro', +}; + +beforeEach(() => { + vi.clearAllMocks(); + mockSelectOption.mockResolvedValue('execute'); + mockSelectRecentSession.mockResolvedValue(null); +}); + +afterEach(() => { + restoreStdin(); +}); + +// ================================================================= +// initializeSession: no implicit session auto-load +// ================================================================= +describe('initializeSession', () => { + it('should return sessionId as undefined (no implicit auto-load)', () => { + const ctx = initializeSession('/test/cwd', 'interactive'); + + expect(ctx.sessionId).toBeUndefined(); + expect(ctx.personaName).toBe('interactive'); + }); +}); + +// ================================================================= +// /resume command +// ================================================================= +describe('/resume command', () => { + it('should call selectRecentSession and update sessionId when session selected', async () => { + // Given: /resume → select session → /cancel + setupRawStdin(toRawInputs(['/resume', '/cancel'])); + setupProvider([]); + mockSelectRecentSession.mockResolvedValue('selected-session-abc'); + + const ctx = createSessionContext(); + + // When + const result = await runConversationLoop('/test', ctx, defaultStrategy, undefined, undefined); + + // Then: selectRecentSession called + expect(mockSelectRecentSession).toHaveBeenCalledWith('/test', 'en'); + + // Then: info about loaded session displayed + expect(mockLogInfo).toHaveBeenCalledWith('Mock label'); + + // Then: cancelled at the end + expect(result.action).toBe('cancel'); + }); + + it('should not change sessionId when user cancels session selection', async () => { + // Given: /resume → cancel selection → /cancel + setupRawStdin(toRawInputs(['/resume', '/cancel'])); + setupProvider([]); + mockSelectRecentSession.mockResolvedValue(null); + + const ctx = createSessionContext(); + + // When + const result = await runConversationLoop('/test', ctx, defaultStrategy, undefined, undefined); + + // Then: selectRecentSession called but returned null + expect(mockSelectRecentSession).toHaveBeenCalledWith('/test', 'en'); + + // Then: cancelled + expect(result.action).toBe('cancel'); + }); + + it('should use resumed session for subsequent AI calls', async () => { + // Given: /resume → select session → send message → /cancel + setupRawStdin(toRawInputs(['/resume', 'hello world', '/cancel'])); + mockSelectRecentSession.mockResolvedValue('resumed-session-xyz'); + + const { provider, capture } = createScenarioProvider([ + { content: 'AI response' }, + ]); + + const ctx: SessionContext = { + provider: provider as SessionContext['provider'], + providerType: 'mock' as SessionContext['providerType'], + model: undefined, + lang: 'en', + personaName: 'interactive', + sessionId: undefined, + }; + + // When + const result = await runConversationLoop('/test', ctx, defaultStrategy, undefined, undefined); + + // Then: AI call should use the resumed session ID + expect(capture.sessionIds[0]).toBe('resumed-session-xyz'); + expect(result.action).toBe('cancel'); + }); +}); diff --git a/src/app/cli/program.ts b/src/app/cli/program.ts index 507c470..47b9614 100644 --- a/src/app/cli/program.ts +++ b/src/app/cli/program.ts @@ -51,7 +51,8 @@ program .option('--pipeline', 'Pipeline mode: non-interactive, no worktree, direct branch creation') .option('--skip-git', 'Skip branch creation, commit, and push (pipeline mode)') .option('--create-worktree ', 'Skip the worktree prompt by explicitly specifying yes or no') - .option('-q, --quiet', 'Minimal output mode: suppress AI output (for CI)'); + .option('-q, --quiet', 'Minimal output mode: suppress AI output (for CI)') + .option('-c, --continue', 'Continue from the last assistant session'); /** * Run pre-action hook: common initialization for all commands. diff --git a/src/app/cli/routing.ts b/src/app/cli/routing.ts index 5b7c5e9..f97b023 100644 --- a/src/app/cli/routing.ts +++ b/src/app/cli/routing.ts @@ -6,7 +6,6 @@ */ import { info, error as logError, withProgress } from '../../shared/ui/index.js'; -import { confirm } from '../../shared/prompt/index.js'; import { getErrorMessage } from '../../shared/utils/index.js'; import { getLabel } from '../../shared/i18n/index.js'; import { fetchIssue, formatIssueAsTask, checkGhCli, parseIssueNumbers, type GitHubIssue } from '../../infra/github/index.js'; @@ -15,7 +14,6 @@ import { executePipeline } from '../../features/pipeline/index.js'; import { interactiveMode, selectInteractiveMode, - selectRecentSession, passthroughMode, quietMode, personaMode, @@ -23,7 +21,7 @@ import { dispatchConversationAction, type InteractiveModeResult, } from '../../features/interactive/index.js'; -import { getPieceDescription, resolveConfigValue, resolveConfigValues } from '../../infra/config/index.js'; +import { getPieceDescription, resolveConfigValue, resolveConfigValues, loadPersonaSessions } from '../../infra/config/index.js'; import { program, resolvedCwd, pipelineMode } from './program.js'; import { resolveAgentOverrides, parseCreateWorktreeOption, isDirectTask } from './helpers.js'; import { loadTaskHistory } from './taskHistory.js'; @@ -172,17 +170,14 @@ export async function executeDefaultAction(task?: string): Promise { switch (selectedMode) { case 'assistant': { let selectedSessionId: string | undefined; - const provider = globalConfig.provider; - if (provider === 'claude') { - const shouldSelectSession = await confirm( - getLabel('interactive.sessionSelector.confirm', lang), - false, - ); - if (shouldSelectSession) { - const sessionId = await selectRecentSession(resolvedCwd, lang); - if (sessionId) { - selectedSessionId = sessionId; - } + if (opts.continue === true) { + const providerType = globalConfig.provider; + const savedSessions = loadPersonaSessions(resolvedCwd, providerType); + const savedSessionId = savedSessions['interactive']; + if (savedSessionId) { + selectedSessionId = savedSessionId; + } else { + info(getLabel('interactive.continueNoSession', lang)); } } result = await interactiveMode(resolvedCwd, initialInput, pieceContext, selectedSessionId); diff --git a/src/features/interactive/conversationLoop.ts b/src/features/interactive/conversationLoop.ts index dd086c8..38ab7cb 100644 --- a/src/features/interactive/conversationLoop.ts +++ b/src/features/interactive/conversationLoop.ts @@ -22,6 +22,7 @@ import { createLogger, getErrorMessage } from '../../shared/utils/index.js'; import { info, error, blankLine, StreamDisplay } from '../../shared/ui/index.js'; import { getLabel, getLabelObject } from '../../shared/i18n/index.js'; import { readMultilineInput } from './lineEditor.js'; +import { selectRecentSession } from './sessionSelector.js'; import { EXIT_SIGINT } from '../../shared/exitCodes.js'; import { type PieceContext, @@ -55,7 +56,11 @@ export interface SessionContext { } /** - * Initialize provider, session, and language for interactive conversation. + * Initialize provider and language for interactive conversation. + * + * Session ID is always undefined (new session). + * Callers that need session continuity pass sessionId explicitly + * (e.g., --continue flag or /resume command). */ export function initializeSession(cwd: string, personaName: string): SessionContext { const globalConfig = resolveConfigValues(cwd, ['language', 'provider', 'model']); @@ -66,10 +71,8 @@ export function initializeSession(cwd: string, personaName: string): SessionCont const providerType = globalConfig.provider as ProviderType; const provider = getProvider(providerType); const model = globalConfig.model as string | undefined; - const savedSessions = loadPersonaSessions(cwd, providerType); - const sessionId: string | undefined = savedSessions[personaName]; - return { provider, providerType, model, lang, personaName, sessionId }; + return { provider, providerType, model, lang, personaName, sessionId: undefined }; } /** @@ -305,6 +308,15 @@ export async function runConversationLoop( return { action: 'cancel', task: '' }; } + if (trimmed === '/resume') { + const selectedId = await selectRecentSession(cwd, ctx.lang); + if (selectedId) { + sessionId = selectedId; + info(getLabel('interactive.resumeSessionLoaded', ctx.lang)); + } + continue; + } + history.push({ role: 'user', content: trimmed }); log.debug('Sending to AI', { messageCount: history.length, sessionId }); process.stdin.pause(); diff --git a/src/shared/i18n/labels_en.yaml b/src/shared/i18n/labels_en.yaml index 8e36598..9e7ab41 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), /cancel (exit)" + intro: "Interactive mode - describe your task. Commands: /go (execute), /play (run now), /resume (load session), /cancel (exit)" resume: "Resuming previous session" noConversation: "No conversation yet. Please describe your task first." summarizeFailed: "Failed to summarize conversation. Please try again." @@ -39,8 +39,9 @@ interactive: confirm: "Reference a previous run's results?" prompt: "Select a run to reference:" noRuns: "No previous runs found." + continueNoSession: "No previous assistant session found. Starting a new session." + resumeSessionLoaded: "Session loaded. Subsequent AI calls will use this session." sessionSelector: - confirm: "Choose a previous session?" prompt: "Resume from a recent session?" newSession: "New session" newSessionDescription: "Start a fresh conversation" diff --git a/src/shared/i18n/labels_ja.yaml b/src/shared/i18n/labels_ja.yaml index 6f1d93b..e2fd435 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(即実行), /cancel(終了)" + intro: "対話モード - タスク内容を入力してください。コマンド: /go(実行), /play(即実行), /resume(セッション読込), /cancel(終了)" resume: "前回のセッションを再開します" noConversation: "まだ会話がありません。まずタスク内容を入力してください。" summarizeFailed: "会話の要約に失敗しました。再度お試しください。" @@ -39,8 +39,9 @@ interactive: confirm: "前回の実行結果を参照しますか?" prompt: "参照するrunを選択してください:" noRuns: "前回のrunが見つかりませんでした。" + continueNoSession: "前回のアシスタントセッションが見つかりません。新しいセッションで開始します。" + resumeSessionLoaded: "セッションを読み込みました。以降のAI呼び出しでこのセッションを使用します。" sessionSelector: - confirm: "前回セッションを選択しますか?" prompt: "直近のセッションを引き継ぎますか?" newSession: "新しいセッション" newSessionDescription: "新しい会話を始める"