diff --git a/.ai/todo.md b/.ai/todo.md index 960883acf..b5835fc66 100644 --- a/.ai/todo.md +++ b/.ai/todo.md @@ -1,7 +1,16 @@ -# PR #16 main conflict resolution +# Codex plan approval backend fix -- [x] Merge main 2e23c71d, retain managed Claude installer and upstream settings behavior. -- [x] Resolve icon migration and removed smoke-test import. -- [x] Full check:all: 2,876 frontend tests, 1,339 Rust tests passed (one ignored); jean:dev rebuilt. -- [x] Agent-browser smoke check: Settings → Claude shows Install latest with PATH selected. -- [x] Pushed merge 342c6083 to open PR #16; GitHub reports MERGEABLE. +- [x] Add focused regression test for active-chat YOLO approval backend. +- [x] Pass active backend through persisted and streaming Build/YOLO approval sends. +- [x] Run targeted tests and frontend gates; `check:all` reaches Rust then stops because local Cargo is unavailable (covered by Docker build). +- [x] Build `jean:dev` and smoke-test the built UI with agent-browser at 1440×1000. +- [x] Review diff, commit, push, and create/update PR. + +## Review + +- Regression test failed before the fix because the YOLO payload omitted `backend`, then passed with `backend: codex`. +- Active-chat persisted and streaming Build/YOLO paths now pass the backend captured with the approved session. +- Fresh review caught and fixed an async ref race; the regression now switches the active backend while persistence is pending. +- Typecheck, lint, targeted tests, and all 2,877 frontend tests pass (full suite used a 10s timeout after two unrelated tests exceeded 5s under load; both also passed in isolation). +- `make docker-build` produced `jean:dev`; agent-browser confirmed the built UI renders cleanly at 1440×1000. +- `bun run check:all` cannot run local Rust commands because Cargo is unavailable; the Docker build compiled the Rust release binary successfully. diff --git a/src/components/chat/hooks/useMessageHandlers.test.tsx b/src/components/chat/hooks/useMessageHandlers.test.tsx new file mode 100644 index 000000000..57671240b --- /dev/null +++ b/src/components/chat/hooks/useMessageHandlers.test.tsx @@ -0,0 +1,111 @@ +import type { PropsWithChildren, RefObject } from 'react' +import { act, renderHook, waitFor } from '@testing-library/react' +import { QueryClient, QueryClientProvider } from '@tanstack/react-query' +import { beforeEach, describe, expect, it, vi } from 'vitest' +import type { EffortLevel, ExecutionMode, ThinkingLevel } from '@/types/chat' +import type { CliBackend } from '@/types/preferences' +import { useChatStore } from '@/store/chat-store' +import { useMessageHandlers } from './useMessageHandlers' + +const mocks = vi.hoisted(() => ({ + invoke: vi.fn(() => Promise.resolve()), + listen: vi.fn(), + markPlanApproved: vi.fn(() => Promise.resolve()), +})) + +vi.mock('@/lib/transport', () => ({ + invoke: mocks.invoke, + listen: mocks.listen, +})) + +vi.mock('@/services/chat', async () => { + const actual = await vi.importActual('@/services/chat') + return { + ...(actual as object), + markPlanApproved: mocks.markPlanApproved, + } +}) + +const ref = (current: T): RefObject => ({ current }) + +function renderMessageHandlers() { + const queryClient = new QueryClient({ + defaultOptions: { queries: { retry: false }, mutations: { retry: false } }, + }) + const sendMessage = { mutate: vi.fn() } + const selectedBackendRef = ref('codex' as CliBackend) + const wrapper = ({ children }: PropsWithChildren) => ( + {children} + ) + const hook = renderHook( + () => + useMessageHandlers({ + activeSessionIdRef: ref('session-1'), + activeWorktreeIdRef: ref('worktree-1'), + activeWorktreePathRef: ref('/repo/worktree'), + selectedModelRef: ref('gpt-6.1-sol'), + buildModelRef: ref(null), + buildBackendRef: ref(null), + buildThinkingLevelRef: ref(null), + buildEffortLevelRef: ref(null), + yoloModelRef: ref(null), + yoloBackendRef: ref(null), + yoloThinkingLevelRef: ref(null), + yoloEffortLevelRef: ref(null), + selectedBackendRef, + getCustomProfileName: () => undefined, + executionModeRef: ref('plan' as ExecutionMode), + selectedThinkingLevelRef: ref('off' as ThinkingLevel), + selectedEffortLevelRef: ref('medium' as EffortLevel), + useAdaptiveThinkingRef: ref(true), + getMcpConfig: () => undefined, + sendMessage, + createSession: { mutateAsync: vi.fn() }, + queryClient, + scrollToBottom: vi.fn(), + markAtBottom: vi.fn(), + inputRef: ref(null), + pendingPlanMessage: null, + projectIdRef: ref(null), + }), + { wrapper } + ) + return { hook, selectedBackendRef, sendMessage } +} + +describe('useMessageHandlers plan approval', () => { + beforeEach(() => { + vi.clearAllMocks() + useChatStore.setState({ + sendingSessionIds: {}, + executionModes: {}, + selectedModels: {}, + lastSentMessages: {}, + errors: {}, + }) + }) + + it('preserves the Codex backend when approving a plan in yolo mode', async () => { + let finishPersistence!: () => void + mocks.markPlanApproved.mockReturnValueOnce( + new Promise(resolve => { + finishPersistence = resolve + }) + ) + const { hook, selectedBackendRef, sendMessage } = renderMessageHandlers() + + act(() => hook.result.current.handlePlanApprovalYolo('plan-message-1')) + selectedBackendRef.current = 'claude' + finishPersistence() + + await waitFor(() => expect(sendMessage.mutate).toHaveBeenCalled()) + expect(sendMessage.mutate).toHaveBeenCalledWith( + expect.objectContaining({ + model: 'gpt-6.1-sol', + backend: 'codex', + executionMode: 'yolo', + }), + expect.any(Object) + ) + }) +}) diff --git a/src/components/chat/hooks/useMessageHandlers.ts b/src/components/chat/hooks/useMessageHandlers.ts index 520e336f1..0d1576c74 100644 --- a/src/components/chat/hooks/useMessageHandlers.ts +++ b/src/components/chat/hooks/useMessageHandlers.ts @@ -745,6 +745,7 @@ export function useMessageHandlers({ worktreePath, message, model: buildModel, + backend: sessionBackend, executionMode: 'build', thinkingLevel: buildThinking, effortLevel: useAdaptiveThinkingRef.current @@ -917,6 +918,7 @@ export function useMessageHandlers({ worktreePath, message, model: yoloModel, + backend: sessionBackendYolo, executionMode: 'yolo', thinkingLevel: yoloThinking, effortLevel: useAdaptiveThinkingRef.current @@ -1031,6 +1033,7 @@ export function useMessageHandlers({ worktreePath, message: buildApprovalMsg, model: streamBuildModel, + backend: streamBuildSessionBackend, executionMode: 'build', thinkingLevel: streamBuildThinking, effortLevel: useAdaptiveThinkingRef.current @@ -1131,6 +1134,7 @@ export function useMessageHandlers({ worktreePath, message: yoloApprovalMsg, model: streamYoloModel, + backend: streamYoloSessionBackend, executionMode: 'yolo', thinkingLevel: streamYoloThinking, effortLevel: useAdaptiveThinkingRef.current