diff --git a/apps/server/src/providers/codex-provider.ts b/apps/server/src/providers/codex-provider.ts index 1359d37a..94a91a33 100644 --- a/apps/server/src/providers/codex-provider.ts +++ b/apps/server/src/providers/codex-provider.ts @@ -51,6 +51,7 @@ import { CODEX_MODELS } from './codex-models.js'; const CODEX_COMMAND = 'codex'; const CODEX_EXEC_SUBCOMMAND = 'exec'; +const CODEX_RESUME_SUBCOMMAND = 'resume'; const CODEX_JSON_FLAG = '--json'; const CODEX_MODEL_FLAG = '--model'; const CODEX_VERSION_FLAG = '--version'; @@ -355,9 +356,14 @@ function resolveSystemPrompt(systemPrompt?: unknown): string | null { return null; } +function buildPromptText(options: ExecuteOptions): string { + return typeof options.prompt === 'string' + ? options.prompt + : extractTextFromContent(options.prompt); +} + function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string | null): string { - const promptText = - typeof options.prompt === 'string' ? options.prompt : extractTextFromContent(options.prompt); + const promptText = buildPromptText(options); const historyText = options.conversationHistory ? formatHistoryAsText(options.conversationHistory) : ''; @@ -370,6 +376,11 @@ function buildCombinedPrompt(options: ExecuteOptions, systemPromptText?: string return `${historyText}${systemSection}${HISTORY_HEADER}${promptText}`; } +function buildResumePrompt(options: ExecuteOptions): string { + const promptText = buildPromptText(options); + return `${HISTORY_HEADER}${promptText}`; +} + function formatConfigValue(value: string | number | boolean): string { return String(value); } @@ -793,16 +804,22 @@ export class CodexProvider extends BaseProvider { } const searchEnabled = codexSettings.enableWebSearch || resolveSearchEnabled(resolvedAllowedTools, restrictTools); - const schemaPath = await writeOutputSchemaFile(options.cwd, options.outputFormat); - const imageBlocks = codexSettings.enableImages ? extractImageBlocks(options.prompt) : []; - const imagePaths = await writeImageFiles(options.cwd, imageBlocks); + const isResumeQuery = Boolean(options.sdkSessionId); + const schemaPath = isResumeQuery + ? null + : await writeOutputSchemaFile(options.cwd, options.outputFormat); + const imageBlocks = + !isResumeQuery && codexSettings.enableImages ? extractImageBlocks(options.prompt) : []; + const imagePaths = isResumeQuery ? [] : await writeImageFiles(options.cwd, imageBlocks); const approvalPolicy = hasMcpServers && options.mcpAutoApproveTools !== undefined ? options.mcpAutoApproveTools ? 'never' : 'on-request' : codexSettings.approvalPolicy; - const promptText = buildCombinedPrompt(options, combinedSystemPrompt); + const promptText = isResumeQuery + ? buildResumePrompt(options) + : buildCombinedPrompt(options, combinedSystemPrompt); const commandPath = executionPlan.cliPath || CODEX_COMMAND; // Build config overrides for max turns and reasoning effort @@ -832,21 +849,30 @@ export class CodexProvider extends BaseProvider { const preExecArgs: string[] = []; // Add additional directories with write access - if (codexSettings.additionalDirs && codexSettings.additionalDirs.length > 0) { + if ( + !isResumeQuery && + codexSettings.additionalDirs && + codexSettings.additionalDirs.length > 0 + ) { for (const dir of codexSettings.additionalDirs) { preExecArgs.push(CODEX_ADD_DIR_FLAG, dir); } } - // If images were written to disk, add the image directory so the CLI can access them + // If images were written to disk, add the image directory so the CLI can access them. + // Note: imagePaths is set to [] when isResumeQuery is true, so this check is sufficient. if (imagePaths.length > 0) { const imageDir = path.join(options.cwd, CODEX_INSTRUCTIONS_DIR, IMAGE_TEMP_DIR); preExecArgs.push(CODEX_ADD_DIR_FLAG, imageDir); } // Model is already bare (no prefix) - validated by executeQuery + const codexCommand = isResumeQuery + ? [CODEX_EXEC_SUBCOMMAND, CODEX_RESUME_SUBCOMMAND] + : [CODEX_EXEC_SUBCOMMAND]; + const args = [ - CODEX_EXEC_SUBCOMMAND, + ...codexCommand, CODEX_YOLO_FLAG, CODEX_SKIP_GIT_REPO_CHECK_FLAG, ...preExecArgs, @@ -855,6 +881,7 @@ export class CodexProvider extends BaseProvider { CODEX_JSON_FLAG, ...configOverrideArgs, ...(schemaPath ? [CODEX_OUTPUT_SCHEMA_FLAG, schemaPath] : []), + ...(options.sdkSessionId ? [options.sdkSessionId] : []), '-', // Read prompt from stdin to avoid shell escaping issues ]; diff --git a/apps/server/src/providers/copilot-provider.ts b/apps/server/src/providers/copilot-provider.ts index 34cfcbce..5ccdfbf0 100644 --- a/apps/server/src/providers/copilot-provider.ts +++ b/apps/server/src/providers/copilot-provider.ts @@ -30,6 +30,7 @@ import { type CopilotRuntimeModel, } from '@automaker/types'; import { createLogger, isAbortError } from '@automaker/utils'; +import { resolveModelString } from '@automaker/model-resolver'; import { CopilotClient, type PermissionRequest } from '@github/copilot-sdk'; import { normalizeTodos, @@ -116,6 +117,12 @@ export interface CopilotError extends Error { suggestion?: string; } +type CopilotSession = Awaited>; +type CopilotSessionOptions = Parameters[0]; +type ResumableCopilotClient = CopilotClient & { + resumeSession?: (sessionId: string, options: CopilotSessionOptions) => Promise; +}; + // ============================================================================= // Tool Name Normalization // ============================================================================= @@ -516,7 +523,11 @@ export class CopilotProvider extends CliProvider { } const promptText = this.extractPromptText(options); - const bareModel = options.model || DEFAULT_BARE_MODEL; + // resolveModelString may return dash-separated canonical names (e.g. "claude-sonnet-4-6"), + // but the Copilot SDK expects dot-separated version suffixes (e.g. "claude-sonnet-4.6"). + // Normalize by converting the last dash-separated numeric pair to dot notation. + const resolvedModel = resolveModelString(options.model || DEFAULT_BARE_MODEL); + const bareModel = resolvedModel.replace(/-(\d+)-(\d+)$/, '-$1.$2'); const workingDirectory = options.cwd || process.cwd(); logger.debug( @@ -554,12 +565,14 @@ export class CopilotProvider extends CliProvider { }); }; + // Declare session outside try so it's accessible in the catch block for cleanup. + let session: CopilotSession | undefined; + try { await client.start(); logger.debug(`CopilotClient started with cwd: ${workingDirectory}`); - // Create session with streaming enabled for real-time events - const session = await client.createSession({ + const sessionOptions: CopilotSessionOptions = { model: bareModel, streaming: true, // AUTONOMOUS MODE: Auto-approve all permission requests. @@ -572,13 +585,33 @@ export class CopilotProvider extends CliProvider { logger.debug(`Permission request: ${request.kind}`); return { kind: 'approved' }; }, - }); + }; - const sessionId = session.sessionId; - logger.debug(`Session created: ${sessionId}`); + // Resume the previous Copilot session when possible; otherwise create a fresh one. + const resumableClient = client as ResumableCopilotClient; + let sessionResumed = false; + if (options.sdkSessionId && typeof resumableClient.resumeSession === 'function') { + try { + session = await resumableClient.resumeSession(options.sdkSessionId, sessionOptions); + sessionResumed = true; + logger.debug(`Resumed Copilot session: ${session.sessionId}`); + } catch (resumeError) { + logger.warn( + `Failed to resume Copilot session "${options.sdkSessionId}", creating a new session: ${resumeError}` + ); + session = await client.createSession(sessionOptions); + } + } else { + session = await client.createSession(sessionOptions); + } + + // session is always assigned by this point (both branches above assign it) + const activeSession = session!; + const sessionId = activeSession.sessionId; + logger.debug(`Session ${sessionResumed ? 'resumed' : 'created'}: ${sessionId}`); // Set up event handler to push events to queue - session.on((event: SdkEvent) => { + activeSession.on((event: SdkEvent) => { logger.debug(`SDK event: ${event.type}`); if (event.type === 'session.idle') { @@ -596,7 +629,7 @@ export class CopilotProvider extends CliProvider { }); // Send the prompt (non-blocking) - await session.send({ prompt: promptText }); + await activeSession.send({ prompt: promptText }); // Process events as they arrive while (!sessionComplete || eventQueue.length > 0) { @@ -604,7 +637,7 @@ export class CopilotProvider extends CliProvider { // Check for errors first (before processing events to avoid race condition) if (sessionError) { - await session.destroy(); + await activeSession.destroy(); await client.stop(); throw sessionError; } @@ -624,11 +657,19 @@ export class CopilotProvider extends CliProvider { } // Cleanup - await session.destroy(); + await activeSession.destroy(); await client.stop(); logger.debug('CopilotClient stopped successfully'); } catch (error) { - // Ensure client is stopped on error + // Ensure session is destroyed and client is stopped on error to prevent leaks. + // The session may have been created/resumed before the error occurred. + if (session) { + try { + await session.destroy(); + } catch (sessionCleanupError) { + logger.debug(`Failed to destroy session during cleanup: ${sessionCleanupError}`); + } + } try { await client.stop(); } catch (cleanupError) { diff --git a/apps/server/src/providers/cursor-provider.ts b/apps/server/src/providers/cursor-provider.ts index 8684417a..450b3a74 100644 --- a/apps/server/src/providers/cursor-provider.ts +++ b/apps/server/src/providers/cursor-provider.ts @@ -450,6 +450,11 @@ export class CursorProvider extends CliProvider { cliArgs.push('--model', model); } + // Resume an existing chat when a provider session ID is available + if (options.sdkSessionId) { + cliArgs.push('--resume', options.sdkSessionId); + } + // Use '-' to indicate reading prompt from stdin cliArgs.push('-'); diff --git a/apps/server/src/providers/gemini-provider.ts b/apps/server/src/providers/gemini-provider.ts index 764c57eb..e4e6f9dc 100644 --- a/apps/server/src/providers/gemini-provider.ts +++ b/apps/server/src/providers/gemini-provider.ts @@ -270,6 +270,11 @@ export class GeminiProvider extends CliProvider { cliArgs.push('--include-directories', options.cwd); } + // Resume an existing Gemini session when one is available + if (options.sdkSessionId) { + cliArgs.push('--resume', options.sdkSessionId); + } + // Note: Gemini CLI doesn't have a --thinking-level flag. // Thinking capabilities are determined by the model selection (e.g., gemini-2.5-pro). // The model handles thinking internally based on the task complexity. diff --git a/apps/server/src/routes/worktree/routes/discard-changes.ts b/apps/server/src/routes/worktree/routes/discard-changes.ts index 914eff67..eb2c9399 100644 --- a/apps/server/src/routes/worktree/routes/discard-changes.ts +++ b/apps/server/src/routes/worktree/routes/discard-changes.ts @@ -5,12 +5,12 @@ * 1. Discard ALL changes (when no files array is provided) * - Resets staged changes (git reset HEAD) * - Discards modified tracked files (git checkout .) - * - Removes untracked files and directories (git clean -fd) + * - Removes untracked files and directories (git clean -ffd) * * 2. Discard SELECTED files (when files array is provided) * - Unstages selected staged files (git reset HEAD -- ) * - Reverts selected tracked file changes (git checkout -- ) - * - Removes selected untracked files (git clean -fd -- ) + * - Removes selected untracked files (git clean -ffd -- ) * * Note: Git repository validation (isGitRepo) is handled by * the requireGitRepoOnly middleware in index.ts @@ -52,6 +52,22 @@ function validateFilePath(filePath: string, worktreePath: string): boolean { } } +/** + * Parse a file path from git status --porcelain output, handling renames. + * For renamed files (R status), git reports "old_path -> new_path" and + * we need the new path to match what parseGitStatus() returns in git-utils. + */ +function parseFilePath(rawPath: string, indexStatus: string, workTreeStatus: string): string { + const trimmedPath = rawPath.trim(); + if (indexStatus === 'R' || workTreeStatus === 'R') { + const arrowIndex = trimmedPath.indexOf(' -> '); + if (arrowIndex !== -1) { + return trimmedPath.slice(arrowIndex + 4); + } + } + return trimmedPath; +} + export function createDiscardChangesHandler() { return async (req: Request, res: Response): Promise => { try { @@ -91,11 +107,16 @@ export function createDiscardChangesHandler() { // Parse the status output to categorize files // Git --porcelain format: XY PATH where X=index status, Y=worktree status - // Preserve the exact two-character XY status (no trim) to keep index vs worktree info + // For renamed files: XY OLD_PATH -> NEW_PATH const statusLines = status.trim().split('\n').filter(Boolean); const allFiles = statusLines.map((line) => { const fileStatus = line.substring(0, 2); - const filePath = line.slice(3).trim(); + const rawPath = line.slice(3); + const indexStatus = fileStatus.charAt(0); + const workTreeStatus = fileStatus.charAt(1); + // Parse path consistently with parseGitStatus() in git-utils, + // which extracts the new path for renames + const filePath = parseFilePath(rawPath, indexStatus, workTreeStatus); return { status: fileStatus, path: filePath }; }); @@ -122,8 +143,12 @@ export function createDiscardChangesHandler() { const untrackedFiles: string[] = []; // Untracked files (?) const warnings: string[] = []; + // Track which requested files were matched so we can handle unmatched ones + const matchedFiles = new Set(); + for (const file of allFiles) { if (!filesToDiscard.has(file.path)) continue; + matchedFiles.add(file.path); // file.status is the raw two-character XY git porcelain status (no trim) // X = index/staging status, Y = worktree status @@ -151,6 +176,16 @@ export function createDiscardChangesHandler() { } } + // Handle files from the UI that didn't match any entry in allFiles. + // This can happen due to timing differences between the UI loading diffs + // and the discard request, or path format differences. + // Attempt to clean unmatched files directly as untracked files. + for (const requestedFile of files) { + if (!matchedFiles.has(requestedFile)) { + untrackedFiles.push(requestedFile); + } + } + // 1. Unstage selected staged files (using execFile to bypass shell) if (stagedFiles.length > 0) { try { @@ -174,9 +209,10 @@ export function createDiscardChangesHandler() { } // 3. Remove selected untracked files + // Use -ffd (double force) to also handle nested git repositories if (untrackedFiles.length > 0) { try { - await execGitCommand(['clean', '-fd', '--', ...untrackedFiles], worktreePath); + await execGitCommand(['clean', '-ffd', '--', ...untrackedFiles], worktreePath); } catch (error) { const msg = getErrorMessage(error); logError(error, `Failed to clean untracked files: ${msg}`); @@ -234,11 +270,12 @@ export function createDiscardChangesHandler() { } // 3. Remove untracked files and directories + // Use -ffd (double force) to also handle nested git repositories try { - await execGitCommand(['clean', '-fd'], worktreePath); + await execGitCommand(['clean', '-ffd', '--'], worktreePath); } catch (error) { const msg = getErrorMessage(error); - logError(error, `git clean -fd failed: ${msg}`); + logError(error, `git clean -ffd failed: ${msg}`); warnings.push(`Failed to remove untracked files: ${msg}`); } diff --git a/apps/server/src/services/agent-executor-types.ts b/apps/server/src/services/agent-executor-types.ts index d449a25e..56a9086c 100644 --- a/apps/server/src/services/agent-executor-types.ts +++ b/apps/server/src/services/agent-executor-types.ts @@ -29,6 +29,7 @@ export interface AgentExecutionOptions { credentials?: Credentials; claudeCompatibleProvider?: ClaudeCompatibleProvider; mcpServers?: Record; + sdkSessionId?: string; sdkOptions?: { maxTurns?: number; allowedTools?: string[]; diff --git a/apps/server/src/services/agent-executor.ts b/apps/server/src/services/agent-executor.ts index 5f45c600..1ef7bed9 100644 --- a/apps/server/src/services/agent-executor.ts +++ b/apps/server/src/services/agent-executor.ts @@ -93,6 +93,7 @@ export class AgentExecutor { credentials, claudeCompatibleProvider, mcpServers, + sdkSessionId, sdkOptions, } = options; const { content: promptContent } = await buildPromptWithImages( @@ -129,6 +130,7 @@ export class AgentExecutor { thinkingLevel: options.thinkingLevel, credentials, claudeCompatibleProvider, + sdkSessionId, }; const featureDirForOutput = getFeatureDir(projectPath, featureId); const outputPath = path.join(featureDirForOutput, 'agent-output.md'); @@ -217,6 +219,9 @@ export class AgentExecutor { try { const stream = provider.executeQuery(executeOptions); streamLoop: for await (const msg of stream) { + if (msg.session_id && msg.session_id !== options.sdkSessionId) { + options.sdkSessionId = msg.session_id; + } receivedAnyStreamMessage = true; appendRawEvent(msg); if (abortController.signal.aborted) { @@ -385,6 +390,9 @@ export class AgentExecutor { taskCompleteDetected = false; for await (const msg of taskStream) { + if (msg.session_id && msg.session_id !== options.sdkSessionId) { + options.sdkSessionId = msg.session_id; + } if (msg.type === 'assistant' && msg.message?.content) { for (const b of msg.message.content) { if (b.type === 'text') { @@ -599,6 +607,9 @@ export class AgentExecutor { for await (const msg of provider.executeQuery( this.buildExecOpts(options, revPrompt, sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS) )) { + if (msg.session_id && msg.session_id !== options.sdkSessionId) { + options.sdkSessionId = msg.session_id; + } if (msg.type === 'assistant' && msg.message?.content) for (const b of msg.message.content) if (b.type === 'text') { @@ -698,6 +709,7 @@ export class AgentExecutor { : undefined, credentials: o.credentials, claudeCompatibleProvider: o.claudeCompatibleProvider, + sdkSessionId: o.sdkSessionId, }; } @@ -717,6 +729,9 @@ export class AgentExecutor { for await (const msg of provider.executeQuery( this.buildExecOpts(options, contPrompt, options.sdkOptions?.maxTurns ?? DEFAULT_MAX_TURNS) )) { + if (msg.session_id && msg.session_id !== options.sdkSessionId) { + options.sdkSessionId = msg.session_id; + } if (msg.type === 'assistant' && msg.message?.content) for (const b of msg.message.content) { if (b.type === 'text') { diff --git a/apps/server/src/services/agent-service.ts b/apps/server/src/services/agent-service.ts index 8d2275e5..eb2f3d9c 100644 --- a/apps/server/src/services/agent-service.ts +++ b/apps/server/src/services/agent-service.ts @@ -329,12 +329,6 @@ export class AgentService { timestamp: new Date().toISOString(), }; - // Build conversation history from existing messages BEFORE adding current message - const conversationHistory = session.messages.map((msg) => ({ - role: msg.role, - content: msg.content, - })); - session.messages.push(userMessage); session.isRunning = true; session.abortController = new AbortController(); @@ -406,6 +400,7 @@ export class AgentService { } } + let combinedSystemPrompt: string | undefined; // Load project context files (CLAUDE.md, CODE_QUALITY.md, etc.) and memory files // Use the user's message as task context for smart memory selection const contextResult = await loadContextFiles({ @@ -423,7 +418,7 @@ export class AgentService { // Build combined system prompt with base prompt and context files const baseSystemPrompt = await this.getSystemPrompt(); - const combinedSystemPrompt = contextFilesPrompt + combinedSystemPrompt = contextFilesPrompt ? `${contextFilesPrompt}\n\n${baseSystemPrompt}` : baseSystemPrompt; @@ -513,6 +508,14 @@ export class AgentService { : stripProviderPrefix(effectiveModel); // Build options for provider + const conversationHistory = session.messages + .slice(0, -1) + .map((msg) => ({ + role: msg.role, + content: msg.content, + })) + .filter((msg) => msg.content.trim().length > 0); + const options: ExecuteOptions = { prompt: '', // Will be set below based on images model: bareModel, // Bare model ID (e.g., "gpt-5.1-codex-max", "composer-1") @@ -522,7 +525,8 @@ export class AgentService { maxTurns: maxTurns, allowedTools: allowedTools, abortController: session.abortController!, - conversationHistory: conversationHistory.length > 0 ? conversationHistory : undefined, + conversationHistory: + conversationHistory && conversationHistory.length > 0 ? conversationHistory : undefined, settingSources: settingSources.length > 0 ? settingSources : undefined, sdkSessionId: session.sdkSessionId, // Pass SDK session ID for resuming mcpServers: Object.keys(mcpServers).length > 0 ? mcpServers : undefined, // Pass MCP servers configuration diff --git a/apps/server/tests/unit/providers/codex-provider.test.ts b/apps/server/tests/unit/providers/codex-provider.test.ts index 0121fd17..1e150ee1 100644 --- a/apps/server/tests/unit/providers/codex-provider.test.ts +++ b/apps/server/tests/unit/providers/codex-provider.test.ts @@ -170,6 +170,30 @@ describe('codex-provider.ts', () => { expect(call.args).toContain('--json'); }); + it('uses exec resume when sdkSessionId is provided', async () => { + vi.mocked(spawnJSONLProcess).mockReturnValue((async function* () {})()); + + await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Continue', + model: 'gpt-5.2', + cwd: '/tmp', + sdkSessionId: 'codex-session-123', + outputFormat: { type: 'json_schema', schema: { type: 'object', properties: {} } }, + codexSettings: { additionalDirs: ['/extra/dir'] }, + }) + ); + + const call = vi.mocked(spawnJSONLProcess).mock.calls[0][0]; + expect(call.args[0]).toBe('exec'); + expect(call.args[1]).toBe('resume'); + expect(call.args).toContain('codex-session-123'); + expect(call.args).toContain('--json'); + // Resume queries must not include --output-schema or --add-dir + expect(call.args).not.toContain('--output-schema'); + expect(call.args).not.toContain('--add-dir'); + }); + it('overrides approval policy when MCP auto-approval is enabled', async () => { // Note: With full-permissions always on (--dangerously-bypass-approvals-and-sandbox), // approval policy is bypassed, not configured via --config diff --git a/apps/server/tests/unit/providers/copilot-provider.test.ts b/apps/server/tests/unit/providers/copilot-provider.test.ts index ccd7ae28..552e7530 100644 --- a/apps/server/tests/unit/providers/copilot-provider.test.ts +++ b/apps/server/tests/unit/providers/copilot-provider.test.ts @@ -1,17 +1,35 @@ import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; import { CopilotProvider, CopilotErrorCode } from '@/providers/copilot-provider.js'; +import { collectAsyncGenerator } from '../../utils/helpers.js'; +import { CopilotClient } from '@github/copilot-sdk'; + +const createSessionMock = vi.fn(); +const resumeSessionMock = vi.fn(); + +function createMockSession(sessionId = 'test-session') { + let eventHandler: ((event: any) => void) | null = null; + return { + sessionId, + send: vi.fn().mockImplementation(async () => { + if (eventHandler) { + eventHandler({ type: 'assistant.message', data: { content: 'hello' } }); + eventHandler({ type: 'session.idle' }); + } + }), + destroy: vi.fn().mockResolvedValue(undefined), + on: vi.fn().mockImplementation((handler: (event: any) => void) => { + eventHandler = handler; + }), + }; +} // Mock the Copilot SDK vi.mock('@github/copilot-sdk', () => ({ CopilotClient: vi.fn().mockImplementation(() => ({ start: vi.fn().mockResolvedValue(undefined), stop: vi.fn().mockResolvedValue(undefined), - createSession: vi.fn().mockResolvedValue({ - sessionId: 'test-session', - send: vi.fn().mockResolvedValue(undefined), - destroy: vi.fn().mockResolvedValue(undefined), - on: vi.fn(), - }), + createSession: createSessionMock, + resumeSession: resumeSessionMock, })), })); @@ -49,6 +67,16 @@ describe('copilot-provider.ts', () => { beforeEach(() => { vi.clearAllMocks(); + vi.mocked(CopilotClient).mockImplementation(function () { + return { + start: vi.fn().mockResolvedValue(undefined), + stop: vi.fn().mockResolvedValue(undefined), + createSession: createSessionMock, + resumeSession: resumeSessionMock, + } as any; + }); + createSessionMock.mockResolvedValue(createMockSession()); + resumeSessionMock.mockResolvedValue(createMockSession('resumed-session')); // Mock fs.existsSync for CLI path validation vi.mocked(fs.existsSync).mockReturnValue(true); @@ -514,4 +542,45 @@ describe('copilot-provider.ts', () => { expect(todoInput.todos[0].status).toBe('completed'); }); }); + + describe('executeQuery resume behavior', () => { + it('uses resumeSession when sdkSessionId is provided', async () => { + const results = await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Hello', + model: 'claude-sonnet-4.6', + cwd: '/tmp/project', + sdkSessionId: 'session-123', + }) + ); + + expect(resumeSessionMock).toHaveBeenCalledWith( + 'session-123', + expect.objectContaining({ model: 'claude-sonnet-4.6', streaming: true }) + ); + expect(createSessionMock).not.toHaveBeenCalled(); + expect(results.some((msg) => msg.session_id === 'resumed-session')).toBe(true); + }); + + it('falls back to createSession when resumeSession fails', async () => { + resumeSessionMock.mockRejectedValueOnce(new Error('session not found')); + createSessionMock.mockResolvedValueOnce(createMockSession('fresh-session')); + + const results = await collectAsyncGenerator( + provider.executeQuery({ + prompt: 'Hello', + model: 'claude-sonnet-4.6', + cwd: '/tmp/project', + sdkSessionId: 'stale-session', + }) + ); + + expect(resumeSessionMock).toHaveBeenCalledWith( + 'stale-session', + expect.objectContaining({ model: 'claude-sonnet-4.6', streaming: true }) + ); + expect(createSessionMock).toHaveBeenCalledTimes(1); + expect(results.some((msg) => msg.session_id === 'fresh-session')).toBe(true); + }); + }); }); diff --git a/apps/server/tests/unit/providers/cursor-provider.test.ts b/apps/server/tests/unit/providers/cursor-provider.test.ts new file mode 100644 index 00000000..9eff5d30 --- /dev/null +++ b/apps/server/tests/unit/providers/cursor-provider.test.ts @@ -0,0 +1,39 @@ +import { describe, it, expect } from 'vitest'; +import { CursorProvider } from '@/providers/cursor-provider.js'; + +describe('cursor-provider.ts', () => { + describe('buildCliArgs', () => { + it('adds --resume when sdkSessionId is provided', () => { + const provider = Object.create(CursorProvider.prototype) as CursorProvider & { + cliPath?: string; + }; + provider.cliPath = '/usr/local/bin/cursor-agent'; + + const args = provider.buildCliArgs({ + prompt: 'Continue the task', + model: 'gpt-5', + cwd: '/tmp/project', + sdkSessionId: 'cursor-session-123', + }); + + const resumeIndex = args.indexOf('--resume'); + expect(resumeIndex).toBeGreaterThan(-1); + expect(args[resumeIndex + 1]).toBe('cursor-session-123'); + }); + + it('does not add --resume when sdkSessionId is omitted', () => { + const provider = Object.create(CursorProvider.prototype) as CursorProvider & { + cliPath?: string; + }; + provider.cliPath = '/usr/local/bin/cursor-agent'; + + const args = provider.buildCliArgs({ + prompt: 'Start a new task', + model: 'gpt-5', + cwd: '/tmp/project', + }); + + expect(args).not.toContain('--resume'); + }); + }); +}); diff --git a/apps/server/tests/unit/providers/gemini-provider.test.ts b/apps/server/tests/unit/providers/gemini-provider.test.ts new file mode 100644 index 00000000..a2d410d4 --- /dev/null +++ b/apps/server/tests/unit/providers/gemini-provider.test.ts @@ -0,0 +1,35 @@ +import { describe, it, expect, beforeEach } from 'vitest'; +import { GeminiProvider } from '@/providers/gemini-provider.js'; + +describe('gemini-provider.ts', () => { + let provider: GeminiProvider; + + beforeEach(() => { + provider = new GeminiProvider(); + }); + + describe('buildCliArgs', () => { + it('should include --resume when sdkSessionId is provided', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/project', + sdkSessionId: 'gemini-session-123', + }); + + const resumeIndex = args.indexOf('--resume'); + expect(resumeIndex).toBeGreaterThan(-1); + expect(args[resumeIndex + 1]).toBe('gemini-session-123'); + }); + + it('should not include --resume when sdkSessionId is missing', () => { + const args = provider.buildCliArgs({ + prompt: 'Hello', + model: '2.5-flash', + cwd: '/tmp/project', + }); + + expect(args).not.toContain('--resume'); + }); + }); +}); diff --git a/apps/server/tests/unit/services/agent-service.test.ts b/apps/server/tests/unit/services/agent-service.test.ts index 96090d2b..c8ae1cba 100644 --- a/apps/server/tests/unit/services/agent-service.test.ts +++ b/apps/server/tests/unit/services/agent-service.test.ts @@ -303,6 +303,36 @@ describe('agent-service.ts', () => { expect(fs.writeFile).toHaveBeenCalled(); }); + + it('should include context/history preparation for Gemini requests', async () => { + let capturedOptions: any; + const mockProvider = { + getName: () => 'gemini', + executeQuery: async function* (options: any) { + capturedOptions = options; + yield { + type: 'result', + subtype: 'success', + }; + }, + }; + + vi.mocked(ProviderFactory.getProviderForModelName).mockReturnValue('gemini'); + vi.mocked(ProviderFactory.getProviderForModel).mockReturnValue(mockProvider as any); + vi.mocked(promptBuilder.buildPromptWithImages).mockResolvedValue({ + content: 'Hello', + hasImages: false, + }); + + await service.sendMessage({ + sessionId: 'session-1', + message: 'Hello', + model: 'gemini-2.5-flash', + }); + + expect(contextLoader.loadContextFiles).toHaveBeenCalled(); + expect(capturedOptions).toBeDefined(); + }); }); describe('stopExecution', () => { diff --git a/apps/ui/src/components/views/agent-view.tsx b/apps/ui/src/components/views/agent-view.tsx index 51c8f7fb..967cafd4 100644 --- a/apps/ui/src/components/views/agent-view.tsx +++ b/apps/ui/src/components/views/agent-view.tsx @@ -1,6 +1,5 @@ import { useState, useCallback, useRef, useEffect } from 'react'; import { useAppStore } from '@/store/app-store'; -import type { PhaseModelEntry } from '@automaker/types'; import { useElectronAgent } from '@/hooks/use-electron-agent'; import { SessionManager } from '@/components/session-manager'; @@ -46,8 +45,6 @@ export function AgentView() { return () => window.removeEventListener('resize', updateVisibility); }, []); - const [modelSelection, setModelSelection] = useState({ model: 'claude-sonnet' }); - // Input ref for auto-focus const inputRef = useRef(null); @@ -57,10 +54,12 @@ export function AgentView() { const createSessionInFlightRef = useRef(false); // Session management hook - scoped to current worktree - const { currentSessionId, handleSelectSession } = useAgentSession({ - projectPath: currentProject?.path, - workingDirectory: effectiveWorkingDirectory, - }); + // Also handles model selection persistence per session + const { currentSessionId, handleSelectSession, modelSelection, setModelSelection } = + useAgentSession({ + projectPath: currentProject?.path, + workingDirectory: effectiveWorkingDirectory, + }); // Use the Electron agent hook (only if we have a session) const { diff --git a/apps/ui/src/components/views/agent-view/hooks/use-agent-session.ts b/apps/ui/src/components/views/agent-view/hooks/use-agent-session.ts index a159cee4..f4c2d2a0 100644 --- a/apps/ui/src/components/views/agent-view/hooks/use-agent-session.ts +++ b/apps/ui/src/components/views/agent-view/hooks/use-agent-session.ts @@ -1,9 +1,14 @@ import { useState, useCallback, useEffect, useRef } from 'react'; import { createLogger } from '@automaker/utils/logger'; +import type { PhaseModelEntry } from '@automaker/types'; import { useAppStore } from '@/store/app-store'; +import { useShallow } from 'zustand/react/shallow'; const logger = createLogger('AgentSession'); +// Default model selection when none is persisted +const DEFAULT_MODEL_SELECTION: PhaseModelEntry = { model: 'claude-sonnet' }; + interface UseAgentSessionOptions { projectPath: string | undefined; workingDirectory?: string; // Current worktree path for per-worktree session persistence @@ -12,14 +17,31 @@ interface UseAgentSessionOptions { interface UseAgentSessionResult { currentSessionId: string | null; handleSelectSession: (sessionId: string | null) => void; + // Model selection persistence + modelSelection: PhaseModelEntry; + setModelSelection: (model: PhaseModelEntry) => void; } export function useAgentSession({ projectPath, workingDirectory, }: UseAgentSessionOptions): UseAgentSessionResult { - const { setLastSelectedSession, getLastSelectedSession } = useAppStore(); + const { + setLastSelectedSession, + getLastSelectedSession, + setAgentModelForSession, + getAgentModelForSession, + } = useAppStore( + useShallow((state) => ({ + setLastSelectedSession: state.setLastSelectedSession, + getLastSelectedSession: state.getLastSelectedSession, + setAgentModelForSession: state.setAgentModelForSession, + getAgentModelForSession: state.getAgentModelForSession, + })) + ); const [currentSessionId, setCurrentSessionId] = useState(null); + const [modelSelection, setModelSelectionState] = + useState(DEFAULT_MODEL_SELECTION); // Track if initial session has been loaded const initialSessionLoadedRef = useRef(false); @@ -27,6 +49,22 @@ export function useAgentSession({ // Use workingDirectory as the persistence key so sessions are scoped per worktree const persistenceKey = workingDirectory || projectPath; + /** + * Fetch persisted model for sessionId and update local state, or fall back to default. + */ + const restoreModelForSession = useCallback( + (sessionId: string) => { + const persistedModel = getAgentModelForSession(sessionId); + if (persistedModel) { + logger.debug('Restoring model selection for session:', sessionId, persistedModel); + setModelSelectionState(persistedModel); + } else { + setModelSelectionState(DEFAULT_MODEL_SELECTION); + } + }, + [getAgentModelForSession] + ); + // Handle session selection with persistence const handleSelectSession = useCallback( (sessionId: string | null) => { @@ -35,16 +73,52 @@ export function useAgentSession({ if (persistenceKey) { setLastSelectedSession(persistenceKey, sessionId); } + // Restore model selection for this session if available + if (sessionId) { + restoreModelForSession(sessionId); + } }, - [persistenceKey, setLastSelectedSession] + [persistenceKey, setLastSelectedSession, restoreModelForSession] ); + // Wrapper for setModelSelection that also persists + const setModelSelection = useCallback( + (model: PhaseModelEntry) => { + setModelSelectionState(model); + // Persist model selection for current session. + // If currentSessionId is null (no active session), we only update local state + // and skip persistence — this is intentional because the model picker should be + // disabled (or hidden) in the UI whenever there is no active session, so this + // path is only reached if the UI allows selection before a session is established. + if (currentSessionId) { + setAgentModelForSession(currentSessionId, model); + } + }, + [currentSessionId, setAgentModelForSession] + ); + + // Track the previous persistence key to detect actual changes + const prevPersistenceKeyRef = useRef(persistenceKey); + // Restore last selected session when switching to Agent view or when worktree changes useEffect(() => { - if (!persistenceKey) { - // No project, reset - setCurrentSessionId(null); + // Detect if persistenceKey actually changed (worktree/project switch) + const persistenceKeyChanged = prevPersistenceKeyRef.current !== persistenceKey; + + if (persistenceKeyChanged) { + // Reset state when switching worktree/project + prevPersistenceKeyRef.current = persistenceKey; initialSessionLoadedRef.current = false; + setCurrentSessionId(null); + setModelSelectionState(DEFAULT_MODEL_SELECTION); + + if (!persistenceKey) { + // No project, nothing to restore + return; + } + } + + if (!persistenceKey) { return; } @@ -54,19 +128,17 @@ export function useAgentSession({ const lastSessionId = getLastSelectedSession(persistenceKey); if (lastSessionId) { - logger.info('Restoring last selected session:', lastSessionId); + logger.debug('Restoring last selected session:', lastSessionId); setCurrentSessionId(lastSessionId); + // Also restore model selection for this session + restoreModelForSession(lastSessionId); } - }, [persistenceKey, getLastSelectedSession]); - - // Reset when worktree/project changes - clear current session and allow restore - useEffect(() => { - initialSessionLoadedRef.current = false; - setCurrentSessionId(null); - }, [persistenceKey]); + }, [persistenceKey, getLastSelectedSession, restoreModelForSession]); return { currentSessionId, handleSelectSession, + modelSelection, + setModelSelection, }; } diff --git a/apps/ui/src/components/views/board-view.tsx b/apps/ui/src/components/views/board-view.tsx index 5d3a33cc..5f2f919a 100644 --- a/apps/ui/src/components/views/board-view.tsx +++ b/apps/ui/src/components/views/board-view.tsx @@ -116,7 +116,6 @@ export function BoardView() { setPendingPlanApproval, updateFeature, batchUpdateFeatures, - getCurrentWorktree, setCurrentWorktree, getWorktrees, setWorktrees, @@ -135,7 +134,6 @@ export function BoardView() { setPendingPlanApproval: state.setPendingPlanApproval, updateFeature: state.updateFeature, batchUpdateFeatures: state.batchUpdateFeatures, - getCurrentWorktree: state.getCurrentWorktree, setCurrentWorktree: state.setCurrentWorktree, getWorktrees: state.getWorktrees, setWorktrees: state.setWorktrees, @@ -444,9 +442,17 @@ export function BoardView() { [batchResetBranchFeatures] ); - // Get current worktree info (path) for filtering features - // This needs to be before useBoardActions so we can pass currentWorktreeBranch - const currentWorktreeInfo = currentProject ? getCurrentWorktree(currentProject.path) : null; + const currentProjectPath = currentProject?.path; + + // Get current worktree info (path/branch) for filtering features. + // Subscribe to the selected project's current worktree value directly so worktree + // switches trigger an immediate re-render and instant kanban/list re-filtering. + const currentWorktreeInfo = useAppStore( + useCallback( + (s) => (currentProjectPath ? (s.currentWorktreeByProject[currentProjectPath] ?? null) : null), + [currentProjectPath] + ) + ); const currentWorktreePath = currentWorktreeInfo?.path ?? null; // Select worktrees for the current project directly from the store. @@ -455,7 +461,6 @@ export function BoardView() { // object, causing unnecessary re-renders that cascaded into selectedWorktree → // useAutoMode → refreshStatus → setAutoModeRunning → store update → re-render loop // that could trigger React error #185 on initial project open). - const currentProjectPath = currentProject?.path; const worktrees = useAppStore( useCallback( (s) => diff --git a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx index c31ff482..10ed992b 100644 --- a/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx +++ b/apps/ui/src/components/views/board-view/dialogs/commit-worktree-dialog.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect, useMemo, useCallback } from 'react'; +import { useState, useEffect, useMemo, useCallback, useRef } from 'react'; import { Dialog, DialogContent, @@ -30,13 +30,17 @@ import { ChevronDown, ChevronRight, Upload, + RefreshCw, } from 'lucide-react'; import { Spinner } from '@/components/ui/spinner'; import { getElectronAPI } from '@/lib/electron'; +import { getHttpApiClient } from '@/lib/http-api-client'; import { toast } from 'sonner'; import { useAppStore } from '@/store/app-store'; +import { resolveModelString } from '@automaker/model-resolver'; import { cn } from '@/lib/utils'; import { TruncatedFilePath } from '@/components/ui/truncated-file-path'; +import { ModelOverrideTrigger, useModelOverride } from '@/components/shared'; import type { FileStatus, MergeStateInfo } from '@/types/electron'; import { parseDiff, type ParsedFileDiff } from '@/lib/diff-utils'; @@ -206,6 +210,11 @@ export function CommitWorktreeDialog({ const [error, setError] = useState(null); const enableAiCommitMessages = useAppStore((state) => state.enableAiCommitMessages); + // Commit message model override + const commitModelOverride = useModelOverride({ phase: 'commitMessageModel' }); + const { effectiveModel: commitEffectiveModel, effectiveModelEntry: commitEffectiveModelEntry } = + commitModelOverride; + // File selection state const [files, setFiles] = useState([]); const [diffContent, setDiffContent] = useState(''); @@ -532,6 +541,46 @@ export function CommitWorktreeDialog({ } }; + // Generate AI commit message + const generateCommitMessage = useCallback(async () => { + if (!worktree) return; + + setIsGenerating(true); + try { + const resolvedCommitModel = resolveModelString(commitEffectiveModel); + const api = getHttpApiClient(); + const result = await api.worktree.generateCommitMessage( + worktree.path, + resolvedCommitModel, + commitEffectiveModelEntry?.thinkingLevel, + commitEffectiveModelEntry?.providerId + ); + + if (result.success && result.message) { + setMessage(result.message); + } else { + console.warn('Failed to generate commit message:', result.error); + toast.error('Failed to generate commit message', { + description: result.error || 'Unknown error', + }); + } + } catch (err) { + console.warn('Error generating commit message:', err); + toast.error('Failed to generate commit message', { + description: err instanceof Error ? err.message : 'Unknown error', + }); + } finally { + setIsGenerating(false); + } + }, [worktree, commitEffectiveModel, commitEffectiveModelEntry]); + + // Keep a stable ref to generateCommitMessage so the open-dialog effect + // doesn't re-fire (and erase user edits) when the model override changes. + const generateCommitMessageRef = useRef(generateCommitMessage); + useEffect(() => { + generateCommitMessageRef.current = generateCommitMessage; + }); + // Generate AI commit message when dialog opens (if enabled) useEffect(() => { if (open && worktree) { @@ -543,45 +592,7 @@ export function CommitWorktreeDialog({ return; } - setIsGenerating(true); - let cancelled = false; - - const generateMessage = async () => { - try { - const api = getElectronAPI(); - if (!api?.worktree?.generateCommitMessage) { - if (!cancelled) { - setIsGenerating(false); - } - return; - } - - const result = await api.worktree.generateCommitMessage(worktree.path); - - if (cancelled) return; - - if (result.success && result.message) { - setMessage(result.message); - } else { - console.warn('Failed to generate commit message:', result.error); - setMessage(''); - } - } catch (err) { - if (cancelled) return; - console.warn('Error generating commit message:', err); - setMessage(''); - } finally { - if (!cancelled) { - setIsGenerating(false); - } - } - }; - - generateMessage(); - - return () => { - cancelled = true; - }; + generateCommitMessageRef.current(); } }, [open, worktree, enableAiCommitMessages]); @@ -589,12 +600,12 @@ export function CommitWorktreeDialog({ const allSelected = selectedFiles.size === files.length && files.length > 0; - // Prevent the dialog from being dismissed while a push is in progress. + // Prevent the dialog from being dismissed while a push or generation is in progress. // Overlay clicks and Escape key both route through onOpenChange(false); we - // intercept those here so the UI stays open until the push completes. + // intercept those here so the UI stays open until the operation completes. const handleOpenChange = (nextOpen: boolean) => { - if (!nextOpen && isPushing) { - // Ignore close requests during an active push. + if (!nextOpen && (isLoading || isPushing || isGenerating)) { + // Ignore close requests during an active commit, push, or generation. return; } onOpenChange(nextOpen); @@ -813,15 +824,46 @@ export function CommitWorktreeDialog({ {/* Commit Message */}
- +
+ +
+ {enableAiCommitMessages && ( + <> + + + + )} +
+