From 96437120e511ffffd7ba3a5d14e3a5cf5cef5953 Mon Sep 17 00:00:00 2001 From: Steven Enamakel <31011319+senamakel@users.noreply.github.com> Date: Thu, 25 Jun 2026 09:24:02 -0700 Subject: [PATCH] Plan-mode review: approve/reject/revise goals & to-dos before execute (#4105) --- app/src/lib/i18n/ar.ts | 8 + app/src/lib/i18n/bn.ts | 8 + app/src/lib/i18n/de.ts | 8 + app/src/lib/i18n/en.ts | 8 + app/src/lib/i18n/es.ts | 8 + app/src/lib/i18n/fr.ts | 8 + app/src/lib/i18n/hi.ts | 8 + app/src/lib/i18n/id.ts | 8 + app/src/lib/i18n/it.ts | 8 + app/src/lib/i18n/ko.ts | 8 + app/src/lib/i18n/pl.ts | 8 + app/src/lib/i18n/pt.ts | 8 + app/src/lib/i18n/ru.ts | 8 + app/src/lib/i18n/zh-CN.ts | 7 + app/src/pages/Conversations.tsx | 36 ++- .../__tests__/Conversations.render.test.tsx | 41 +-- .../components/PlanReviewCard.test.tsx | 90 ++++++ .../components/PlanReviewCard.tsx | 162 +++++++++++ .../conversations/taskPlanActions.test.ts | 4 +- app/src/providers/ChatRuntimeProvider.tsx | 23 +- .../__tests__/ChatRuntimeProvider.test.tsx | 58 ++++ app/src/services/chatService.ts | 28 ++ app/src/store/chatRuntimeSlice.ts | 32 +++ docs/TEST-COVERAGE-MATRIX.md | 1 + src/core/all.rs | 3 + src/core/event_bus/events.rs | 30 ++ src/core/jsonrpc.rs | 19 +- src/openhuman/about_app/catalog_data.rs | 10 + src/openhuman/agent/task_dispatcher/poller.rs | 23 +- src/openhuman/agent/task_dispatcher/tests.rs | 14 +- src/openhuman/agent/tools/todo.rs | 6 + .../agents/orchestrator/agent.toml | 27 +- .../agents/orchestrator/prompt.md | 1 + .../channels/providers/web/event_bus.rs | 37 ++- src/openhuman/mod.rs | 1 + src/openhuman/plan_review/gate.rs | 261 ++++++++++++++++++ src/openhuman/plan_review/mod.rs | 24 ++ src/openhuman/plan_review/schemas.rs | 133 +++++++++ src/openhuman/plan_review/tool.rs | 198 +++++++++++++ src/openhuman/plan_review/types.rs | 37 +++ src/openhuman/todos/ops.rs | 72 +++++ src/openhuman/todos/schemas.rs | 41 +++ src/openhuman/tools/ops.rs | 3 + tests/json_rpc_e2e.rs | 134 +++++++++ 44 files changed, 1597 insertions(+), 63 deletions(-) create mode 100644 app/src/pages/conversations/components/PlanReviewCard.test.tsx create mode 100644 app/src/pages/conversations/components/PlanReviewCard.tsx create mode 100644 src/openhuman/plan_review/gate.rs create mode 100644 src/openhuman/plan_review/mod.rs create mode 100644 src/openhuman/plan_review/schemas.rs create mode 100644 src/openhuman/plan_review/tool.rs create mode 100644 src/openhuman/plan_review/types.rs diff --git a/app/src/lib/i18n/ar.ts b/app/src/lib/i18n/ar.ts index ada5ec3c7..0d8fd9411 100644 --- a/app/src/lib/i18n/ar.ts +++ b/app/src/lib/i18n/ar.ts @@ -2989,6 +2989,14 @@ const messages: TranslationMap = { 'conversations.taskKanban.moveRight': 'نقل لليمين', 'conversations.taskKanban.title': 'المهام', 'conversations.threadTodo.title': 'الخطة', + 'conversations.planReview.title': 'مراجعة الخطة', + 'conversations.planReview.subtitle': + 'وافق لتشغيلها، أو ارفضها لتجاهلها، أو أرسل ملاحظات لتعديلها.', + 'conversations.planReview.approve': 'الموافقة والتشغيل', + 'conversations.planReview.reject': 'رفض', + 'conversations.planReview.feedbackLabel': 'طلب تغييرات', + 'conversations.planReview.feedbackPlaceholder': 'صف ما الذي يجب تغييره…', + 'conversations.planReview.sendFeedback': 'إرسال الملاحظات', 'conversations.threadGoal.label': 'الهدف', 'conversations.threadGoal.setCta': 'تحديد هدف', 'conversations.threadGoal.placeholder': 'ما الذي يجب أن تحققه هذه المحادثة؟', diff --git a/app/src/lib/i18n/bn.ts b/app/src/lib/i18n/bn.ts index a6a6d5ab3..8a777053e 100644 --- a/app/src/lib/i18n/bn.ts +++ b/app/src/lib/i18n/bn.ts @@ -3055,6 +3055,14 @@ const messages: TranslationMap = { 'conversations.taskKanban.moveRight': 'ডানে সরান', 'conversations.taskKanban.title': 'টাস্ক', 'conversations.threadTodo.title': 'পরিকল্পনা', + 'conversations.planReview.title': 'পরিকল্পনা পর্যালোচনা করুন', + 'conversations.planReview.subtitle': + 'চালানোর জন্য অনুমোদন করুন, বাতিল করতে প্রত্যাখ্যান করুন, অথবা সংশোধনের জন্য মতামত পাঠান।', + 'conversations.planReview.approve': 'অনুমোদন ও চালান', + 'conversations.planReview.reject': 'প্রত্যাখ্যান', + 'conversations.planReview.feedbackLabel': 'পরিবর্তন অনুরোধ করুন', + 'conversations.planReview.feedbackPlaceholder': 'কী পরিবর্তন করতে হবে বর্ণনা করুন…', + 'conversations.planReview.sendFeedback': 'মতামত পাঠান', 'conversations.threadGoal.label': 'লক্ষ্য', 'conversations.threadGoal.setCta': 'লক্ষ্য নির্ধারণ', 'conversations.threadGoal.placeholder': 'এই থ্রেডে কী অর্জন করতে হবে?', diff --git a/app/src/lib/i18n/de.ts b/app/src/lib/i18n/de.ts index d24d6c4a6..726ca9656 100644 --- a/app/src/lib/i18n/de.ts +++ b/app/src/lib/i18n/de.ts @@ -3126,6 +3126,14 @@ const messages: TranslationMap = { 'conversations.taskKanban.moveRight': 'Bewege dich nach rechts', 'conversations.taskKanban.title': 'Aufgaben', 'conversations.threadTodo.title': 'Plan', + 'conversations.planReview.title': 'Plan prüfen', + 'conversations.planReview.subtitle': + 'Genehmigen zum Ausführen, ablehnen zum Verwerfen oder Feedback zum Überarbeiten senden.', + 'conversations.planReview.approve': 'Genehmigen & ausführen', + 'conversations.planReview.reject': 'Ablehnen', + 'conversations.planReview.feedbackLabel': 'Änderungen anfordern', + 'conversations.planReview.feedbackPlaceholder': 'Beschreiben Sie, was geändert werden soll…', + 'conversations.planReview.sendFeedback': 'Feedback senden', 'conversations.threadGoal.label': 'Ziel', 'conversations.threadGoal.setCta': 'Ziel festlegen', 'conversations.threadGoal.placeholder': 'Was soll dieser Thread erreichen?', diff --git a/app/src/lib/i18n/en.ts b/app/src/lib/i18n/en.ts index 944269f07..c2664a74d 100644 --- a/app/src/lib/i18n/en.ts +++ b/app/src/lib/i18n/en.ts @@ -3590,6 +3590,14 @@ const en: TranslationMap = { 'conversations.taskKanban.moveRight': 'Move right', 'conversations.taskKanban.title': 'Tasks', 'conversations.threadTodo.title': 'Plan', + 'conversations.planReview.title': 'Review plan', + 'conversations.planReview.subtitle': + 'Approve to run it, reject to discard, or send feedback to revise.', + 'conversations.planReview.approve': 'Approve & run', + 'conversations.planReview.reject': 'Reject', + 'conversations.planReview.feedbackLabel': 'Request changes', + 'conversations.planReview.feedbackPlaceholder': 'Describe what to change…', + 'conversations.planReview.sendFeedback': 'Send feedback', // Thread-level goal chip (Codex-style per-thread completion contract). 'conversations.threadGoal.label': 'Goal', 'conversations.threadGoal.setCta': 'Set goal', diff --git a/app/src/lib/i18n/es.ts b/app/src/lib/i18n/es.ts index a483cc325..9cc55210c 100644 --- a/app/src/lib/i18n/es.ts +++ b/app/src/lib/i18n/es.ts @@ -3104,6 +3104,14 @@ const messages: TranslationMap = { 'conversations.taskKanban.moveRight': 'Mover a la derecha', 'conversations.taskKanban.title': 'Tareas', 'conversations.threadTodo.title': 'Plan', + 'conversations.planReview.title': 'Revisar plan', + 'conversations.planReview.subtitle': + 'Aprueba para ejecutarlo, recházalo para descartarlo o envía comentarios para revisarlo.', + 'conversations.planReview.approve': 'Aprobar y ejecutar', + 'conversations.planReview.reject': 'Rechazar', + 'conversations.planReview.feedbackLabel': 'Solicitar cambios', + 'conversations.planReview.feedbackPlaceholder': 'Describe qué cambiar…', + 'conversations.planReview.sendFeedback': 'Enviar comentarios', 'conversations.threadGoal.label': 'Objetivo', 'conversations.threadGoal.setCta': 'Definir objetivo', 'conversations.threadGoal.placeholder': '¿Qué debe lograr este hilo?', diff --git a/app/src/lib/i18n/fr.ts b/app/src/lib/i18n/fr.ts index aaeb3f60e..e1aea1dfc 100644 --- a/app/src/lib/i18n/fr.ts +++ b/app/src/lib/i18n/fr.ts @@ -3119,6 +3119,14 @@ const messages: TranslationMap = { 'conversations.taskKanban.moveRight': 'Déplacer à droite', 'conversations.taskKanban.title': 'Tâches', 'conversations.threadTodo.title': 'Plan', + 'conversations.planReview.title': 'Examiner le plan', + 'conversations.planReview.subtitle': + 'Approuvez pour l’exécuter, rejetez pour l’abandonner ou envoyez un retour pour le réviser.', + 'conversations.planReview.approve': 'Approuver et exécuter', + 'conversations.planReview.reject': 'Rejeter', + 'conversations.planReview.feedbackLabel': 'Demander des modifications', + 'conversations.planReview.feedbackPlaceholder': 'Décrivez ce qu’il faut changer…', + 'conversations.planReview.sendFeedback': 'Envoyer un retour', 'conversations.threadGoal.label': 'Objectif', 'conversations.threadGoal.setCta': 'Définir un objectif', 'conversations.threadGoal.placeholder': 'Que doit accomplir ce fil ?', diff --git a/app/src/lib/i18n/hi.ts b/app/src/lib/i18n/hi.ts index 00713099b..1dfba806e 100644 --- a/app/src/lib/i18n/hi.ts +++ b/app/src/lib/i18n/hi.ts @@ -3055,6 +3055,14 @@ const messages: TranslationMap = { 'conversations.taskKanban.moveRight': 'दाएं ले जाएं', 'conversations.taskKanban.title': 'टास्क', 'conversations.threadTodo.title': 'योजना', + 'conversations.planReview.title': 'योजना की समीक्षा करें', + 'conversations.planReview.subtitle': + 'इसे चलाने के लिए स्वीकृत करें, हटाने के लिए अस्वीकार करें, या संशोधित करने के लिए प्रतिक्रिया भेजें।', + 'conversations.planReview.approve': 'स्वीकृत करें और चलाएँ', + 'conversations.planReview.reject': 'अस्वीकार करें', + 'conversations.planReview.feedbackLabel': 'बदलाव का अनुरोध करें', + 'conversations.planReview.feedbackPlaceholder': 'वर्णन करें कि क्या बदलना है…', + 'conversations.planReview.sendFeedback': 'प्रतिक्रिया भेजें', 'conversations.threadGoal.label': 'लक्ष्य', 'conversations.threadGoal.setCta': 'लक्ष्य सेट करें', 'conversations.threadGoal.placeholder': 'इस थ्रेड को क्या हासिल करना चाहिए?', diff --git a/app/src/lib/i18n/id.ts b/app/src/lib/i18n/id.ts index 1b2b09bf8..d28b59192 100644 --- a/app/src/lib/i18n/id.ts +++ b/app/src/lib/i18n/id.ts @@ -3058,6 +3058,14 @@ const messages: TranslationMap = { 'conversations.taskKanban.moveRight': 'Pindah ke kanan', 'conversations.taskKanban.title': 'Tugas', 'conversations.threadTodo.title': 'Rencana', + 'conversations.planReview.title': 'Tinjau rencana', + 'conversations.planReview.subtitle': + 'Setujui untuk menjalankannya, tolak untuk membuangnya, atau kirim masukan untuk merevisinya.', + 'conversations.planReview.approve': 'Setujui & jalankan', + 'conversations.planReview.reject': 'Tolak', + 'conversations.planReview.feedbackLabel': 'Minta perubahan', + 'conversations.planReview.feedbackPlaceholder': 'Jelaskan apa yang harus diubah…', + 'conversations.planReview.sendFeedback': 'Kirim masukan', 'conversations.threadGoal.label': 'Tujuan', 'conversations.threadGoal.setCta': 'Tetapkan tujuan', 'conversations.threadGoal.placeholder': 'Apa yang harus dicapai utas ini?', diff --git a/app/src/lib/i18n/it.ts b/app/src/lib/i18n/it.ts index 4ce407d0e..3174e2dab 100644 --- a/app/src/lib/i18n/it.ts +++ b/app/src/lib/i18n/it.ts @@ -3098,6 +3098,14 @@ const messages: TranslationMap = { 'conversations.taskKanban.moveRight': 'Sposta a destra', 'conversations.taskKanban.title': 'Attività', 'conversations.threadTodo.title': 'Piano', + 'conversations.planReview.title': 'Rivedi il piano', + 'conversations.planReview.subtitle': + 'Approva per eseguirlo, rifiuta per scartarlo o invia un feedback per rivederlo.', + 'conversations.planReview.approve': 'Approva ed esegui', + 'conversations.planReview.reject': 'Rifiuta', + 'conversations.planReview.feedbackLabel': 'Richiedi modifiche', + 'conversations.planReview.feedbackPlaceholder': 'Descrivi cosa cambiare…', + 'conversations.planReview.sendFeedback': 'Invia feedback', 'conversations.threadGoal.label': 'Obiettivo', 'conversations.threadGoal.setCta': 'Imposta obiettivo', 'conversations.threadGoal.placeholder': 'Cosa deve realizzare questa conversazione?', diff --git a/app/src/lib/i18n/ko.ts b/app/src/lib/i18n/ko.ts index cfef73266..6cdb0db51 100644 --- a/app/src/lib/i18n/ko.ts +++ b/app/src/lib/i18n/ko.ts @@ -3028,6 +3028,14 @@ const messages: TranslationMap = { 'conversations.taskKanban.moveRight': '오른쪽으로 이동', 'conversations.taskKanban.title': '작업', 'conversations.threadTodo.title': '계획', + 'conversations.planReview.title': '계획 검토', + 'conversations.planReview.subtitle': + '실행하려면 승인하고, 버리려면 거부하거나, 수정하려면 의견을 보내세요.', + 'conversations.planReview.approve': '승인 후 실행', + 'conversations.planReview.reject': '거부', + 'conversations.planReview.feedbackLabel': '변경 요청', + 'conversations.planReview.feedbackPlaceholder': '무엇을 바꿀지 설명하세요…', + 'conversations.planReview.sendFeedback': '의견 보내기', 'conversations.threadGoal.label': '목표', 'conversations.threadGoal.setCta': '목표 설정', 'conversations.threadGoal.placeholder': '이 스레드가 무엇을 달성해야 하나요?', diff --git a/app/src/lib/i18n/pl.ts b/app/src/lib/i18n/pl.ts index ecd632eb9..f603e130a 100644 --- a/app/src/lib/i18n/pl.ts +++ b/app/src/lib/i18n/pl.ts @@ -3085,6 +3085,14 @@ const messages: TranslationMap = { 'conversations.taskKanban.moveRight': 'Przesuń w prawo', 'conversations.taskKanban.title': 'Zadania', 'conversations.threadTodo.title': 'Plan', + 'conversations.planReview.title': 'Przejrzyj plan', + 'conversations.planReview.subtitle': + 'Zatwierdź, aby go uruchomić, odrzuć, aby go odrzucić, lub wyślij opinię, aby go poprawić.', + 'conversations.planReview.approve': 'Zatwierdź i uruchom', + 'conversations.planReview.reject': 'Odrzuć', + 'conversations.planReview.feedbackLabel': 'Poproś o zmiany', + 'conversations.planReview.feedbackPlaceholder': 'Opisz, co zmienić…', + 'conversations.planReview.sendFeedback': 'Wyślij opinię', 'conversations.threadGoal.label': 'Cel', 'conversations.threadGoal.setCta': 'Ustaw cel', 'conversations.threadGoal.placeholder': 'Co ma osiągnąć ten wątek?', diff --git a/app/src/lib/i18n/pt.ts b/app/src/lib/i18n/pt.ts index 221ed0207..0ef31b242 100644 --- a/app/src/lib/i18n/pt.ts +++ b/app/src/lib/i18n/pt.ts @@ -3103,6 +3103,14 @@ const messages: TranslationMap = { 'conversations.taskKanban.moveRight': 'Mover para direita', 'conversations.taskKanban.title': 'Tarefas', 'conversations.threadTodo.title': 'Plano', + 'conversations.planReview.title': 'Revisar plano', + 'conversations.planReview.subtitle': + 'Aprove para executá-lo, rejeite para descartá-lo ou envie comentários para revisá-lo.', + 'conversations.planReview.approve': 'Aprovar e executar', + 'conversations.planReview.reject': 'Rejeitar', + 'conversations.planReview.feedbackLabel': 'Solicitar alterações', + 'conversations.planReview.feedbackPlaceholder': 'Descreva o que mudar…', + 'conversations.planReview.sendFeedback': 'Enviar comentários', 'conversations.threadGoal.label': 'Objetivo', 'conversations.threadGoal.setCta': 'Definir objetivo', 'conversations.threadGoal.placeholder': 'O que esta conversa deve realizar?', diff --git a/app/src/lib/i18n/ru.ts b/app/src/lib/i18n/ru.ts index 35200542b..52abf4a11 100644 --- a/app/src/lib/i18n/ru.ts +++ b/app/src/lib/i18n/ru.ts @@ -3077,6 +3077,14 @@ const messages: TranslationMap = { 'conversations.taskKanban.moveRight': 'Переместить вправо', 'conversations.taskKanban.title': 'Задачи', 'conversations.threadTodo.title': 'План', + 'conversations.planReview.title': 'Проверить план', + 'conversations.planReview.subtitle': + 'Одобрите для запуска, отклоните для отмены или отправьте отзыв для доработки.', + 'conversations.planReview.approve': 'Одобрить и запустить', + 'conversations.planReview.reject': 'Отклонить', + 'conversations.planReview.feedbackLabel': 'Запросить изменения', + 'conversations.planReview.feedbackPlaceholder': 'Опишите, что изменить…', + 'conversations.planReview.sendFeedback': 'Отправить отзыв', 'conversations.threadGoal.label': 'Цель', 'conversations.threadGoal.setCta': 'Задать цель', 'conversations.threadGoal.placeholder': 'Чего должен достичь этот тред?', diff --git a/app/src/lib/i18n/zh-CN.ts b/app/src/lib/i18n/zh-CN.ts index d598e42e0..aeaa68e5c 100644 --- a/app/src/lib/i18n/zh-CN.ts +++ b/app/src/lib/i18n/zh-CN.ts @@ -2907,6 +2907,13 @@ const messages: TranslationMap = { 'conversations.taskKanban.moveRight': '向右移动', 'conversations.taskKanban.title': '任务', 'conversations.threadTodo.title': '计划', + 'conversations.planReview.title': '审阅计划', + 'conversations.planReview.subtitle': '批准以执行,拒绝以放弃,或发送反馈以修改。', + 'conversations.planReview.approve': '批准并执行', + 'conversations.planReview.reject': '拒绝', + 'conversations.planReview.feedbackLabel': '请求修改', + 'conversations.planReview.feedbackPlaceholder': '描述需要修改的内容…', + 'conversations.planReview.sendFeedback': '发送反馈', 'conversations.threadGoal.label': '目标', 'conversations.threadGoal.setCta': '设定目标', 'conversations.threadGoal.placeholder': '这个对话需要完成什么?', diff --git a/app/src/pages/Conversations.tsx b/app/src/pages/Conversations.tsx index 517487e34..2c9528b92 100644 --- a/app/src/pages/Conversations.tsx +++ b/app/src/pages/Conversations.tsx @@ -100,6 +100,7 @@ import { selectBackgroundProcesses, } from './conversations/components/BackgroundProcessesPanel'; import { CitationChips, type MessageCitation } from './conversations/components/CitationChips'; +import { PlanReviewCard } from './conversations/components/PlanReviewCard'; import { SubagentDrawer } from './conversations/components/SubagentDrawer'; import { ThreadGoalEditorPanel, @@ -114,7 +115,6 @@ import { handleComposerSlashCommand, } from './conversations/composerSendDecision'; import { useMemorySyncActive } from './conversations/hooks/useBackgroundActivity'; -import { runDecidePlan } from './conversations/taskPlanActions'; import { type AgentBubblePosition, buildAcceptedInlineCompletion, @@ -321,6 +321,9 @@ const Conversations = ({ const pendingApprovalByThread = useAppSelector( state => state.chatRuntime.pendingApprovalByThread ); + const pendingPlanReviewByThread = useAppSelector( + state => state.chatRuntime.pendingPlanReviewByThread + ); const streamingAssistantByThread = useAppSelector( state => state.chatRuntime.streamingAssistantByThread ); @@ -1503,6 +1506,12 @@ const Conversations = ({ : undefined; const selectedTaskBoard = selectedThreadId ? (taskBoardByThread[selectedThreadId] ?? null) : null; const hasTaskBoard = Boolean(selectedTaskBoard?.cards.length); + // A plan the orchestrator parked for interactive review (request_plan_review + // gate). When present, the PlanReviewCard renders above the composer and + // resolves the parked turn; the todo strip stays read-only progress. + const pendingPlanReview = selectedThreadId + ? (pendingPlanReviewByThread[selectedThreadId] ?? null) + : null; const visibleMessages = messages.filter(msg => !msg.extraMetadata?.hidden); const hasVisibleMessages = visibleMessages.length > 0; const latestVisibleMessage = visibleMessages[visibleMessages.length - 1] ?? null; @@ -2672,20 +2681,25 @@ const Conversations = ({ pinned above the composer. Distinct from the Intelligence-tab kanban (global `user-tasks`). Renders nothing when the thread has no active cards. */} + {/* Plan-mode review: the orchestrator parked the live turn on a + thread-scoped plan (request_plan_review gate). Surface it for the + user to Approve / Reject / send feedback on before anything executes; + the card resolves the parked turn via plan_review_decide. */} + {selectedThreadId && pendingPlanReview && ( + // Key by request id so a re-parked (revised) plan — or a thread switch — + // remounts the card and resets its local decision/feedback state, + // matching the ApprovalRequestCard pattern above. + + )} + {selectedThreadId && ( { - void runDecidePlan({ - threadId: selectedThreadId, - card, - approve, - dispatch, - notify: setSendAdvisory, - t, - }); - }} onViewSession={card => { if (!card.sessionThreadId) return; // Navigation only — do NOT mark the thread active. activeThreadId diff --git a/app/src/pages/__tests__/Conversations.render.test.tsx b/app/src/pages/__tests__/Conversations.render.test.tsx index 2aadc8569..181ee0a64 100644 --- a/app/src/pages/__tests__/Conversations.render.test.tsx +++ b/app/src/pages/__tests__/Conversations.render.test.tsx @@ -22,6 +22,7 @@ import chatRuntimeReducer, { appendProcessingProse, beginInferenceTurn, setInferenceStatusForThread, + setPendingPlanReviewForThread, setTaskBoardForThread, setToolTimelineForThread, } from '../../store/chatRuntimeSlice'; @@ -157,6 +158,14 @@ vi.mock('../../features/autocomplete/useAutocompleteSkillStatus', () => ({ // openUrl uses Tauri; stub it. vi.mock('../../utils/openUrl', () => ({ openUrl: vi.fn() })); +// coreRpcClient: the PlanReviewCard resolves a parked plan via callCoreRpc. +// Preserve the real exports (e.g. CoreRpcError) and only stub the call. +const mockCallCoreRpc = vi.fn().mockResolvedValue({}); +vi.mock('../../services/coreRpcClient', async orig => { + const actual = await orig(); + return { ...actual, callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args) }; +}); + // coreState/store: getCoreStateSnapshot used by selectSocketStatus. vi.mock('../../lib/coreState/store', () => ({ getCoreStateSnapshot: vi.fn(() => ({ @@ -2127,7 +2136,8 @@ describe('Conversations — open-session resume (View work)', () => { expect(screen.getByTestId('route-path')).toHaveTextContent('/human'); }); - it('approves a parked plan card from the thread todo strip', async () => { + it('approves a parked plan from the plan-review card', async () => { + mockCallCoreRpc.mockClear().mockResolvedValue({}); const thread = makeThread({ id: 'approve-thread', title: 'Approve thread' }); mockGetThreads.mockResolvedValue({ threads: [thread], count: 1 }); @@ -2135,33 +2145,26 @@ describe('Conversations — open-session resume (View work)', () => { const selectedId = store.getState().thread.selectedThreadId ?? 'approve-thread'; await act(async () => { store.dispatch( - setTaskBoardForThread({ + setPendingPlanReviewForThread({ threadId: selectedId, - board: { - threadId: selectedId, - updatedAt: '', - cards: [ - { - id: 'pc1', - title: 'Needs sign-off', - status: 'awaiting_approval', - order: 0, - updatedAt: '', - }, - ], - }, + review: { requestId: 'pr-1', summary: 'Needs sign-off', steps: ['do the thing'] }, }) ); }); - // The strip surfaces Approve/Reject only for parked cards; approving routes - // through onDecidePlan → runDecidePlan → threadApi.decidePlan. - const approveBtn = await screen.findByTitle('Approve'); + // A parked plan surfaces the PlanReviewCard above the composer; "Approve & + // run" resolves the parked turn via the plan_review_decide RPC. + const approveBtn = await screen.findByText('Approve & run'); await act(async () => { fireEvent.click(approveBtn); }); - await waitFor(() => expect(threadApi.decidePlan).toHaveBeenCalledWith(selectedId, 'pc1', true)); + await waitFor(() => + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.plan_review_decide', + params: { request_id: 'pr-1', decision: 'approve', feedback: undefined }, + }) + ); }); }); diff --git a/app/src/pages/conversations/components/PlanReviewCard.test.tsx b/app/src/pages/conversations/components/PlanReviewCard.test.tsx new file mode 100644 index 000000000..48a61dc84 --- /dev/null +++ b/app/src/pages/conversations/components/PlanReviewCard.test.tsx @@ -0,0 +1,90 @@ +import { fireEvent, render, screen, waitFor } from '@testing-library/react'; +import { beforeEach, describe, expect, it, vi } from 'vitest'; + +import type { PendingPlanReview } from '../../../store/chatRuntimeSlice'; +import { PlanReviewCard } from './PlanReviewCard'; + +// Echo i18n keys so we can assert on the stable key string. +vi.mock('../../../lib/i18n/I18nContext', () => ({ useT: () => ({ t: (key: string) => key }) })); + +const mockCallCoreRpc = vi.fn(); +vi.mock('../../../services/coreRpcClient', () => ({ + callCoreRpc: (...args: unknown[]) => mockCallCoreRpc(...args), +})); + +const mockDispatch = vi.fn(); +vi.mock('../../../store/hooks', () => ({ useAppDispatch: () => mockDispatch })); + +function review(partial: Partial = {}): PendingPlanReview { + return { + requestId: 'r1', + summary: 'Ship the release', + steps: ['step one', 'step two'], + ...partial, + }; +} + +describe('PlanReviewCard', () => { + beforeEach(() => { + mockCallCoreRpc.mockReset().mockResolvedValue({}); + mockDispatch.mockReset(); + }); + + it('renders the summary and ordered steps', () => { + render(); + expect(screen.getByText('Ship the release')).toBeInTheDocument(); + expect(screen.getByText('step one')).toBeInTheDocument(); + expect(screen.getByText('step two')).toBeInTheDocument(); + }); + + it('approves via plan_review_decide and clears optimistically', async () => { + render(); + fireEvent.click(screen.getByText('conversations.planReview.approve')); + await waitFor(() => + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.plan_review_decide', + params: { request_id: 'r1', decision: 'approve', feedback: undefined }, + }) + ); + expect(mockDispatch).toHaveBeenCalledTimes(1); + }); + + it('rejects via plan_review_decide', async () => { + render(); + fireEvent.click(screen.getByText('conversations.planReview.reject')); + await waitFor(() => + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.plan_review_decide', + params: { request_id: 'r1', decision: 'reject', feedback: undefined }, + }) + ); + }); + + it('sends trimmed feedback as a revise decision; ignores blank input', async () => { + render(); + const send = screen.getByText('conversations.planReview.sendFeedback'); + const textarea = screen.getByTestId('plan-review-feedback') as HTMLTextAreaElement; + + // Blank → disabled, no call. + fireEvent.click(send); + expect(mockCallCoreRpc).not.toHaveBeenCalled(); + + fireEvent.change(textarea, { target: { value: ' add a verification step ' } }); + fireEvent.click(send); + await waitFor(() => + expect(mockCallCoreRpc).toHaveBeenCalledWith({ + method: 'openhuman.plan_review_decide', + params: { request_id: 'r1', decision: 'revise', feedback: 'add a verification step' }, + }) + ); + }); + + it('surfaces an error and stays mounted when the RPC fails', async () => { + mockCallCoreRpc.mockRejectedValueOnce(new Error('boom')); + render(); + fireEvent.click(screen.getByText('conversations.planReview.approve')); + await waitFor(() => expect(screen.getByText(/chat\.approval\.error/)).toBeInTheDocument()); + // Not cleared on failure. + expect(mockDispatch).not.toHaveBeenCalled(); + }); +}); diff --git a/app/src/pages/conversations/components/PlanReviewCard.tsx b/app/src/pages/conversations/components/PlanReviewCard.tsx new file mode 100644 index 000000000..6df310a52 --- /dev/null +++ b/app/src/pages/conversations/components/PlanReviewCard.tsx @@ -0,0 +1,162 @@ +import debug from 'debug'; +import React, { useState } from 'react'; + +import Button from '../../../components/ui/Button'; +import { useT } from '../../../lib/i18n/I18nContext'; +import { callCoreRpc } from '../../../services/coreRpcClient'; +import { + clearPendingPlanReviewForThread, + type PendingPlanReview, +} from '../../../store/chatRuntimeSlice'; +import { useAppDispatch } from '../../../store/hooks'; + +/** + * Plan-mode review surface (Codex/Claude-style). The orchestrator parked the + * live turn on a thread-scoped plan via the `request_plan_review` gate; this + * card surfaces the plan above the composer and resolves the parked turn via + * the `openhuman.plan_review_decide` RPC: + * + * - **Approve & run** → the turn resumes and executes the plan. + * - **Reject** → the turn resumes and stops without executing. + * - **Send feedback** → the turn resumes, re-plans from the free-text request, + * and re-parks for another review. + * + * Mirrors {@link ApprovalRequestCard}: it owns the decision RPC and clears + * itself optimistically; {@link ChatRuntimeProvider}'s turn-end handlers also + * clear the pending review if the turn ends. + */ +const log = debug('openhuman:chat:plan-review-card'); + +type Decision = 'approve' | 'reject' | 'revise'; + +interface Props { + threadId: string; + review: PendingPlanReview; +} + +export const PlanReviewCard: React.FC = ({ threadId, review }) => { + const { t } = useT(); + const dispatch = useAppDispatch(); + const [feedback, setFeedback] = useState(''); + const [deciding, setDeciding] = useState(null); + const [errorMsg, setErrorMsg] = useState(null); + + const decide = async (decision: Decision, feedbackText?: string) => { + if (deciding) return; + setDeciding(decision); + setErrorMsg(null); + try { + await callCoreRpc({ + method: 'openhuman.plan_review_decide', + params: { request_id: review.requestId, decision, feedback: feedbackText }, + }); + // Resolve optimistically; ChatRuntimeProvider also clears on turn end. + dispatch(clearPendingPlanReviewForThread({ threadId })); + } catch (e) { + log('plan_review_decide failed: %o', e); + setErrorMsg(t('chat.approval.error')); + setDeciding(null); + } + }; + + const submitFeedback = () => { + const trimmed = feedback.trim(); + if (!trimmed) return; + void decide('revise', trimmed); + }; + + return ( +
+
+ + 🗺️ + +
+

+ {t('conversations.planReview.title')} +

+

+ {review.summary?.trim() || t('conversations.planReview.subtitle')} +

+ + {review.steps.length > 0 && ( +
    + {review.steps.map((step, i) => ( +
  1. + {step} +
  2. + ))} +
+ )} + + {errorMsg &&

⚠ {errorMsg}

} + +
+ + +
+ +
+ +