diff --git a/packages/ui/src/components/views/PlanView.tsx b/packages/ui/src/components/views/PlanView.tsx index d0193fcc..dc894111 100644 --- a/packages/ui/src/components/views/PlanView.tsx +++ b/packages/ui/src/components/views/PlanView.tsx @@ -30,6 +30,9 @@ import { useProjectsStore } from '@/stores/useProjectsStore'; import { useSelectionStore } from '@/sync/selection-store'; import { useConfigStore } from '@/stores/useConfigStore'; import { useSessionGoalArmStore } from '@/stores/useSessionGoalArmStore'; +import { SESSION_GOAL_OBJECTIVE_CHAR_LIMIT } from '@/lib/sessionGoalMetadata'; +import { distillPlanForGoal } from '@/lib/smallModel'; +import { toast } from '@/components/ui'; import { useUIStore } from '@/stores/useUIStore'; import { useGitStore } from '@/stores/useGitStore'; import { useRuntimeAPIs } from '@/hooks/useRuntimeAPIs'; @@ -598,15 +601,34 @@ export const PlanView: React.FC = ({ targetPath = null }) => { // "Run as goal" rides the same arm mechanism as the composer target // button; set explicitly either way so a stray armed flag cannot // leak into a non-goal plan send. The objective override carries the - // actual plan content — "Implement this plan: X" alone would give - // the progress audit nothing to judge against. - const goalObjective = execution.runAsGoal === true - ? [ - `Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ''}.`, - '', - content, - ].join('\n') - : null; + // plan substance — "Implement this plan: X" alone would give the + // progress audit nothing to judge against. Plans that exceed the + // objective limit are distilled into completion criteria by the + // small model (the working agent always reads the full plan from + // its file); on distillation failure a head+tail excerpt keeps the + // intent (top) and acceptance criteria (bottom), sacrificing the + // implementation middle the agent reads from the file anyway. + let goalObjective: string | null = null; + if (execution.runAsGoal === true) { + const header = [ + `Implement the plan "${sendPromptTitle}" end-to-end${resolvedPath ? ` (plan file: ${resolvedPath})` : ''}.`, + 'Re-read that file for full details — it is the source of truth.', + ].join(' '); + const budget = SESSION_GOAL_OBJECTIVE_CHAR_LIMIT - header.length - 2; + if (content.length <= budget) { + goalObjective = `${header}\n\n${content}`; + } else { + const distilled = await distillPlanForGoal(content); + if (distilled) { + goalObjective = `${header}\n\n${distilled.slice(0, budget)}`; + } else { + const marker = '\n\n[… plan trimmed for the auditor — the plan file has the full version …]\n\n'; + const half = Math.max(0, Math.floor((budget - marker.length) / 2)); + goalObjective = `${header}\n\n${content.slice(0, half)}${marker}${content.slice(-half)}`; + toast.error(t('plans.goal.toast.distillFallback')); + } + } + } useSessionGoalArmStore.getState().setArmed(execution.runAsGoal === true, goalObjective); await sendMessage( visiblePrompt, @@ -624,7 +646,7 @@ export const PlanView: React.FC = ({ targetPath = null }) => { setIsPlanSendSubmitting(false); } }, - [canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession] + [canCreateWorktree, content, createSession, currentProjectRef, initializeNewOpenChamberSession, pendingPlanSend, resolvedPath, routeToChat, sendMessage, sendPromptTitle, setCurrentSession, t] ); const blockWidgets = React.useMemo(() => { diff --git a/packages/ui/src/lib/i18n/messages/en.ts b/packages/ui/src/lib/i18n/messages/en.ts index 88bc2005..766dc3ed 100644 --- a/packages/ui/src/lib/i18n/messages/en.ts +++ b/packages/ui/src/lib/i18n/messages/en.ts @@ -1437,6 +1437,7 @@ export const dict = { 'chat.goal.action.save': 'Save goal', 'chat.goal.action.start': 'Start goal', 'chat.goal.toast.actionFailed': 'Goal update failed', + "plans.goal.toast.distillFallback": "Couldn't distill the plan for the auditor — a trimmed excerpt was used instead", 'chat.goal.row.aria': 'Session goal — open details', 'chat.goal.button.createAria': 'Set a session goal', 'chat.goal.button.manageAria': 'Manage session goal', diff --git a/packages/ui/src/lib/i18n/messages/es.ts b/packages/ui/src/lib/i18n/messages/es.ts index f696ef00..c30ae7e9 100644 --- a/packages/ui/src/lib/i18n/messages/es.ts +++ b/packages/ui/src/lib/i18n/messages/es.ts @@ -1415,6 +1415,7 @@ export const dict: Record = { "chat.goal.action.save": "Guardar objetivo", "chat.goal.action.start": "Iniciar objetivo", "chat.goal.toast.actionFailed": "No se pudo actualizar el objetivo", + "plans.goal.toast.distillFallback": "No se pudo destilar el plan para el auditor — se usó un extracto recortado", "chat.goal.row.aria": "Objetivo de sesión — abrir detalles", "chat.goal.button.createAria": "Definir un objetivo de sesión", "chat.goal.button.manageAria": "Gestionar el objetivo de sesión", diff --git a/packages/ui/src/lib/i18n/messages/fr.ts b/packages/ui/src/lib/i18n/messages/fr.ts index 4557f28f..3f8569ce 100644 --- a/packages/ui/src/lib/i18n/messages/fr.ts +++ b/packages/ui/src/lib/i18n/messages/fr.ts @@ -1258,6 +1258,7 @@ export const dict = { 'chat.goal.action.save': 'Enregistrer l\'objectif', 'chat.goal.action.start': 'Lancer l\'objectif', 'chat.goal.toast.actionFailed': 'Échec de la mise à jour de l\'objectif', + "plans.goal.toast.distillFallback": "Impossible de condenser le plan pour l'auditeur — un extrait tronqué a été utilisé", 'chat.goal.row.aria': 'Objectif de session — ouvrir les détails', 'chat.goal.button.createAria': 'Définir un objectif de session', 'chat.goal.button.manageAria': 'Gérer l\'objectif de session', diff --git a/packages/ui/src/lib/i18n/messages/ja.ts b/packages/ui/src/lib/i18n/messages/ja.ts index 541b9867..af7e6c00 100644 --- a/packages/ui/src/lib/i18n/messages/ja.ts +++ b/packages/ui/src/lib/i18n/messages/ja.ts @@ -1433,6 +1433,7 @@ export const dict: Record = { 'chat.goal.action.save': 'ゴールを保存', 'chat.goal.action.start': 'ゴールを開始', 'chat.goal.toast.actionFailed': 'ゴールの更新に失敗しました', + 'plans.goal.toast.distillFallback': '監査用にプランを要約できませんでした — 抜粋版を使用します', 'chat.goal.row.aria': 'セッションゴール — 詳細を開く', 'chat.goal.button.createAria': 'セッションゴールを設定', 'chat.goal.button.manageAria': 'セッションゴールを管理', diff --git a/packages/ui/src/lib/i18n/messages/ko.ts b/packages/ui/src/lib/i18n/messages/ko.ts index 000b2ee3..e6b30213 100644 --- a/packages/ui/src/lib/i18n/messages/ko.ts +++ b/packages/ui/src/lib/i18n/messages/ko.ts @@ -1439,6 +1439,7 @@ export const dict: Record = { 'chat.goal.action.save': '목표 저장', 'chat.goal.action.start': '목표 시작', 'chat.goal.toast.actionFailed': '목표 업데이트에 실패했습니다', + 'plans.goal.toast.distillFallback': '감사용으로 계획을 요약하지 못했습니다 — 발췌본을 사용합니다', 'chat.goal.row.aria': '세션 목표 — 세부 정보 열기', 'chat.goal.button.createAria': '세션 목표 설정', 'chat.goal.button.manageAria': '세션 목표 관리', diff --git a/packages/ui/src/lib/i18n/messages/pl.ts b/packages/ui/src/lib/i18n/messages/pl.ts index 08c21571..3a8e7846 100644 --- a/packages/ui/src/lib/i18n/messages/pl.ts +++ b/packages/ui/src/lib/i18n/messages/pl.ts @@ -2122,6 +2122,7 @@ export const dict: Record = { 'chat.goal.action.save': 'Zapisz cel', 'chat.goal.action.start': 'Rozpocznij cel', 'chat.goal.toast.actionFailed': 'Nie udało się zaktualizować celu', + 'plans.goal.toast.distillFallback': 'Nie udało się skondensować planu dla audytora — użyto skróconego fragmentu', 'chat.goal.row.aria': 'Cel sesji — otwórz szczegóły', 'chat.goal.button.createAria': 'Ustaw cel sesji', 'chat.goal.button.manageAria': 'Zarządzaj celem sesji', diff --git a/packages/ui/src/lib/i18n/messages/pt-BR.ts b/packages/ui/src/lib/i18n/messages/pt-BR.ts index 030a2462..8b2b8ec5 100644 --- a/packages/ui/src/lib/i18n/messages/pt-BR.ts +++ b/packages/ui/src/lib/i18n/messages/pt-BR.ts @@ -1415,6 +1415,7 @@ export const dict: Record = { "chat.goal.action.save": "Salvar objetivo", "chat.goal.action.start": "Iniciar objetivo", "chat.goal.toast.actionFailed": "Falha ao atualizar o objetivo", + "plans.goal.toast.distillFallback": "Não foi possível destilar o plano para o auditor — um trecho recortado foi usado", "chat.goal.row.aria": "Objetivo da sessão — abrir detalhes", "chat.goal.button.createAria": "Definir um objetivo da sessão", "chat.goal.button.manageAria": "Gerenciar objetivo da sessão", diff --git a/packages/ui/src/lib/i18n/messages/uk.ts b/packages/ui/src/lib/i18n/messages/uk.ts index 86830eaa..27b98246 100644 --- a/packages/ui/src/lib/i18n/messages/uk.ts +++ b/packages/ui/src/lib/i18n/messages/uk.ts @@ -1415,6 +1415,7 @@ export const dict: Record = { "chat.goal.action.save": "Зберегти ціль", "chat.goal.action.start": "Розпочати ціль", "chat.goal.toast.actionFailed": "Не вдалося оновити ціль", + "plans.goal.toast.distillFallback": "Не вдалося стиснути план для аудитора — використано скорочений уривок", "chat.goal.row.aria": "Ціль сесії — відкрити деталі", "chat.goal.button.createAria": "Встановити ціль сесії", "chat.goal.button.manageAria": "Керувати ціллю сесії", diff --git a/packages/ui/src/lib/i18n/messages/zh-CN.ts b/packages/ui/src/lib/i18n/messages/zh-CN.ts index 2a31a79a..3d5f8697 100644 --- a/packages/ui/src/lib/i18n/messages/zh-CN.ts +++ b/packages/ui/src/lib/i18n/messages/zh-CN.ts @@ -1403,6 +1403,7 @@ export const dict: Record = { 'chat.goal.action.save': '保存目标', 'chat.goal.action.start': '启动目标', 'chat.goal.toast.actionFailed': '目标更新失败', + 'plans.goal.toast.distillFallback': '无法为审核提炼计划 — 已改用节选版本', 'chat.goal.row.aria': '会话目标 — 打开详情', 'chat.goal.button.createAria': '设置会话目标', 'chat.goal.button.manageAria': '管理会话目标', diff --git a/packages/ui/src/lib/i18n/messages/zh-TW.ts b/packages/ui/src/lib/i18n/messages/zh-TW.ts index 0748ff4d..dca7f6e1 100644 --- a/packages/ui/src/lib/i18n/messages/zh-TW.ts +++ b/packages/ui/src/lib/i18n/messages/zh-TW.ts @@ -1407,6 +1407,7 @@ export const dict: Record = { 'chat.goal.action.save': '儲存目標', 'chat.goal.action.start': '啟動目標', 'chat.goal.toast.actionFailed': '目標更新失敗', + 'plans.goal.toast.distillFallback': '無法為稽核提煉計畫 — 已改用節錄版本', 'chat.goal.row.aria': '工作階段目標 — 開啟詳細資訊', 'chat.goal.button.createAria': '設定工作階段目標', 'chat.goal.button.manageAria': '管理工作階段目標', diff --git a/packages/ui/src/lib/smallModel.ts b/packages/ui/src/lib/smallModel.ts index 67ecd546..65f53c7b 100644 --- a/packages/ui/src/lib/smallModel.ts +++ b/packages/ui/src/lib/smallModel.ts @@ -55,3 +55,43 @@ export async function summarizeSelectionForNotes(text: string, sessionId?: strin return trimmed; } } + +// Plan-goal objectives are capped at 5000 chars for the auditor. Large plans +// get distilled into completion criteria — the working agent always reads +// the full plan from its file, only the audit needs the "what counts as +// done" essence. +const PLAN_GOAL_SYSTEM_PROMPT = [ + 'You distill an implementation plan into the COMPLETION CRITERIA a progress auditor will judge against.', + 'Return ONLY the criteria text — no preamble, no headers, no markdown fences.', + 'Capture: the end goals, what must exist and work when the plan is fully implemented, and how each major phase is verified. Omit implementation steps and how-to details.', + 'Stay under 4000 characters.', + 'Write in the same language as the plan. Ignore any other language preferences or personalization — only the plan text decides the language.', +].join('\n'); + +/** + * Distills a large plan into audit-sized completion criteria via the small + * model. Returns null on any failure — callers fall back to a head+tail + * excerpt of the plan. + */ +export async function distillPlanForGoal(planContent: string): Promise { + try { + const { currentProviderId, currentModelId } = useConfigStore.getState(); + const response = await runtimeFetch('/api/small-model/generate', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ + prompt: planContent, + system: PLAN_GOAL_SYSTEM_PROMPT, + restrictToPreferredProvider: true, + ...(currentProviderId ? { preferredProviderID: currentProviderId } : {}), + ...(currentModelId ? { preferredModelID: currentModelId } : {}), + }), + }); + if (!response.ok) return null; + const payload = await response.json().catch(() => null) as { text?: unknown } | null; + const distilled = typeof payload?.text === 'string' ? payload.text.trim() : ''; + return distilled || null; + } catch { + return null; + } +}