From 99bf39a2a88d7b141c6b2b53986cc4015c079262 Mon Sep 17 00:00:00 2001 From: Jorben Date: Thu, 18 Jun 2026 21:11:52 +0800 Subject: [PATCH 1/2] =?UTF-8?q?feat(agent):=20=E2=9C=A8=20add=20goal-drive?= =?UTF-8?q?n=20plan=20approval=20action?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new plan approval option "Set goal and implement" that creates a persistent goal before starting implementation, enabling the goal continuation loop to drive execution of approved plans. Previously, users could only approve plans for direct implementation or implementation with context reset. This adds a third option that: - Creates a persistent goal record via GoalManager before the run starts - Uses a simplified implementation prompt referencing the plan file path - Resets context similar to the context-reset action - Is only offered when the thread has no existing goal record - Supports review checkpoints following the plan at each stage The action is surfaced in both backend (Rust PlanApprovalAction enum and approval metadata construction) and frontend (TypeScript types, UI components, and i18n translations in English and Chinese). --- src-tauri/src/commands/agent.rs | 1 + src-tauri/src/core/agent_run_manager.rs | 31 ++++++- src-tauri/src/core/agent_run_summary.rs | 17 ++-- src-tauri/src/core/agent_session_execution.rs | 9 ++- src-tauri/src/core/plan_checkpoint.rs | 81 ++++++++++++++----- src/i18n/locales/en.ts | 2 + src/i18n/locales/zh-CN.ts | 6 +- .../ui/runtime-thread-surface-metadata.ts | 5 +- .../ui/runtime-thread-surface-state.ts | 14 ++-- .../ui/runtime-thread-surface.tsx | 2 +- src/services/bridge/agent-commands.ts | 2 +- src/services/thread-stream/thread-stream.ts | 2 +- 12 files changed, 131 insertions(+), 41 deletions(-) diff --git a/src-tauri/src/commands/agent.rs b/src-tauri/src/commands/agent.rs index 0b73853a..e8568c10 100644 --- a/src-tauri/src/commands/agent.rs +++ b/src-tauri/src/commands/agent.rs @@ -145,6 +145,7 @@ pub async fn thread_execute_approved_plan( let action = match action.as_str() { "apply_plan" => PlanApprovalAction::ApplyPlan, "apply_plan_with_context_reset" => PlanApprovalAction::ApplyPlanWithContextReset, + "apply_plan_with_goal" => PlanApprovalAction::ApplyPlanWithGoal, other => { return Err(AppError::recoverable( crate::model::errors::ErrorSource::Thread, diff --git a/src-tauri/src/core/agent_run_manager.rs b/src-tauri/src/core/agent_run_manager.rs index e6c8a6ad..6c3ab5c0 100644 --- a/src-tauri/src/core/agent_run_manager.rs +++ b/src-tauri/src/core/agent_run_manager.rs @@ -439,11 +439,23 @@ impl AgentRunManager { plan_metadata.approval_state = IMPLEMENTATION_PLAN_APPROVED_STATE.to_string(); - let implementation_prompt = - build_implementation_handoff_prompt(thread_id, &plan_metadata, action.clone()); + let implementation_prompt = match action { + PlanApprovalAction::ApplyPlanWithGoal => { + // Build the goal objective text using the plan file path. + let plan_path = crate::core::plan_checkpoint::plan_file_path(thread_id) + .map(|p| p.display().to_string()) + .unwrap_or_default(); + format!( + "Please follow the implementation plan {} to complete all implementation, and conduct reviews at each stage following the plan, ensuring the implementation follows the design in the plan and meets quality standards.", + plan_path + ) + } + _ => build_implementation_handoff_prompt(thread_id, &plan_metadata, action.clone()), + }; let (history_override, context_seed_messages) = match action { PlanApprovalAction::ApplyPlan => (None, None), - PlanApprovalAction::ApplyPlanWithContextReset => { + PlanApprovalAction::ApplyPlanWithContextReset + | PlanApprovalAction::ApplyPlanWithGoal => { let message_bundle = self .build_context_reset_message_bundle(thread_id, &plan_metadata) .await?; @@ -454,6 +466,19 @@ impl AgentRunManager { } }; + // For ApplyPlanWithGoal: create a persistent goal before starting the + // implementation run so the goal continuation loop can drive execution. + if let PlanApprovalAction::ApplyPlanWithGoal = action { + let goal_manager = crate::core::goal_manager::GoalManager::new( + self.pool.clone(), + thread_id.to_string(), + Arc::clone(&self.goal_runtime_state), + ); + goal_manager + .create_goal(&implementation_prompt, None) + .await?; + } + let result = self .start_run_with_options( thread_id, diff --git a/src-tauri/src/core/agent_run_summary.rs b/src-tauri/src/core/agent_run_summary.rs index e51b9120..d8452458 100644 --- a/src-tauri/src/core/agent_run_summary.rs +++ b/src-tauri/src/core/agent_run_summary.rs @@ -82,6 +82,9 @@ pub(crate) fn build_implementation_handoff_prompt( PlanApprovalAction::ApplyPlanWithContextReset => { "The user approved this plan after clearing the planning conversation from the implementation context." } + PlanApprovalAction::ApplyPlanWithGoal => { + "The user approved this plan for goal-driven implementation with context reset." + } }; let plan_file_note = crate::core::plan_checkpoint::plan_file_path(thread_id) .filter(|path| path.exists()) @@ -99,12 +102,14 @@ pub(crate) fn build_implementation_handoff_prompt( &plan_markdown, ) } - PlanApprovalAction::ApplyPlanWithContextReset => render_handoff_template_no_plan( - include_str!("prompt/templates/handoff/without_plan.tpl.md"), - action_note, - &metadata.artifact.plan_revision.to_string(), - &plan_file_note, - ), + PlanApprovalAction::ApplyPlanWithContextReset | PlanApprovalAction::ApplyPlanWithGoal => { + render_handoff_template_no_plan( + include_str!("prompt/templates/handoff/without_plan.tpl.md"), + action_note, + &metadata.artifact.plan_revision.to_string(), + &plan_file_note, + ) + } } } diff --git a/src-tauri/src/core/agent_session_execution.rs b/src-tauri/src/core/agent_session_execution.rs index fa98d07e..fb858070 100644 --- a/src-tauri/src/core/agent_session_execution.rs +++ b/src-tauri/src/core/agent_session_execution.rs @@ -1039,8 +1039,13 @@ impl AgentSession { let approval_message_id = uuid::Uuid::now_v7().to_string(); let plan_metadata = build_plan_message_metadata(artifact.clone(), &self.spec.run_id, &self.spec.run_mode); - let approval_metadata = - build_approval_prompt_metadata(artifact.plan_revision, &plan_message_id); + let approval_metadata = build_approval_prompt_metadata( + &self.pool, + &self.spec.thread_id, + artifact.plan_revision, + &plan_message_id, + ) + .await; let plan_message = MessageRecord { id: plan_message_id.clone(), diff --git a/src-tauri/src/core/plan_checkpoint.rs b/src-tauri/src/core/plan_checkpoint.rs index a9898967..a9c572eb 100644 --- a/src-tauri/src/core/plan_checkpoint.rs +++ b/src-tauri/src/core/plan_checkpoint.rs @@ -11,13 +11,15 @@ pub const IMPLEMENTATION_PLAN_SUPERSEDED_STATE: &str = "superseded"; pub enum PlanApprovalAction { ApplyPlan, ApplyPlanWithContextReset, + ApplyPlanWithGoal, } impl PlanApprovalAction { pub fn label(&self) -> &'static str { match self { Self::ApplyPlan => "按计划实施", - Self::ApplyPlanWithContextReset => "清理上下文后按计划实施", + Self::ApplyPlanWithContextReset => "清理上下文后实施", + Self::ApplyPlanWithGoal => "按计划设置Goal后实施", } } } @@ -103,27 +105,44 @@ pub fn build_plan_message_metadata( } } -pub fn build_approval_prompt_metadata( +pub async fn build_approval_prompt_metadata( + pool: &sqlx::SqlitePool, + thread_id: &str, plan_revision: u32, plan_message_id: &str, ) -> ApprovalPromptMetadata { + let mut options = vec![ + PlanApprovalOption { + action: PlanApprovalAction::ApplyPlan, + label: PlanApprovalAction::ApplyPlan.label().to_string(), + }, + PlanApprovalOption { + action: PlanApprovalAction::ApplyPlanWithContextReset, + label: PlanApprovalAction::ApplyPlanWithContextReset + .label() + .to_string(), + }, + ]; + + // Only offer the goal-driven option when the thread has no existing goal + // record (any status: active, paused, budget_limited, or complete). + if crate::persistence::repo::goal_repo::find_by_thread_id(pool, thread_id) + .await + .map(|opt| opt.is_none()) + .unwrap_or(false) + { + options.push(PlanApprovalOption { + action: PlanApprovalAction::ApplyPlanWithGoal, + label: PlanApprovalAction::ApplyPlanWithGoal.label().to_string(), + }); + } + ApprovalPromptMetadata { kind: IMPLEMENTATION_PLAN_APPROVAL_KIND.to_string(), plan_revision, plan_message_id: plan_message_id.to_string(), state: IMPLEMENTATION_PLAN_PENDING_STATE.to_string(), - options: vec![ - PlanApprovalOption { - action: PlanApprovalAction::ApplyPlan, - label: PlanApprovalAction::ApplyPlan.label().to_string(), - }, - PlanApprovalOption { - action: PlanApprovalAction::ApplyPlanWithContextReset, - label: PlanApprovalAction::ApplyPlanWithContextReset - .label() - .to_string(), - }, - ], + options, expires_on_new_user_message: true, approved_action: None, } @@ -424,6 +443,24 @@ mod tests { parse_approval_prompt_metadata, parse_plan_message_metadata, plan_design_markdown, plan_markdown, write_plan_file_to, PlanApprovalAction, }; + use sqlx::sqlite::{SqliteConnectOptions, SqlitePoolOptions}; + use std::str::FromStr; + + async fn setup_test_pool() -> sqlx::SqlitePool { + let options = SqliteConnectOptions::from_str("sqlite::memory:") + .expect("invalid sqlite options") + .foreign_keys(true); + let pool = SqlitePoolOptions::new() + .max_connections(1) + .connect_with(options) + .await + .expect("pool"); + sqlx::migrate!("./migrations") + .run(&pool) + .await + .expect("migrations"); + pool + } #[test] fn plan_artifact_builder_accepts_string_and_object_steps() { @@ -470,8 +507,9 @@ mod tests { ); } - #[test] - fn metadata_round_trip_is_stable() { + #[tokio::test] + async fn metadata_round_trip_is_stable() { + let pool = setup_test_pool().await; let artifact = build_plan_artifact_from_tool_input( &serde_json::json!({ "title": "Implement checkpoint", @@ -484,11 +522,18 @@ mod tests { parse_plan_message_metadata(&serde_json::to_value(&metadata).unwrap()).unwrap(); assert_eq!(parsed.artifact, artifact); - let approval = build_approval_prompt_metadata(artifact.plan_revision, "msg-plan"); + let approval = build_approval_prompt_metadata( + &pool, + "thread-no-goal", + artifact.plan_revision, + "msg-plan", + ) + .await; let parsed_approval = parse_approval_prompt_metadata(&serde_json::to_value(&approval).unwrap()).unwrap(); assert_eq!(parsed_approval.plan_revision, 1); - assert_eq!(parsed_approval.options.len(), 2); + // With no existing goal, the third option (ApplyPlanWithGoal) is included. + assert_eq!(parsed_approval.options.len(), 3); assert_eq!( parsed_approval.options[0].action, PlanApprovalAction::ApplyPlan diff --git a/src/i18n/locales/en.ts b/src/i18n/locales/en.ts index f3fd2940..dc201839 100644 --- a/src/i18n/locales/en.ts +++ b/src/i18n/locales/en.ts @@ -138,8 +138,10 @@ const en: Record = { // ── Runtime Thread Surface (Plan Approval) ─────────────── "plan.implementAsPlan": "Implement as planned", "plan.clearAndImplement": "Clear context and implement", + "plan.goalImplement": "Set goal and implement", "plan.approvedClearAndImplement": "Approved: Clear context and implement", "plan.approvedImplement": "Approved: Implement as planned", + "plan.approvedGoalImplement": "Approved: Set goal and implement", "plan.approvedToImplement": "Approved to implement", "plan.superseded": "This plan has been superseded by a newer version.", "plan.awaitingApproval": "Awaiting implementation approval", diff --git a/src/i18n/locales/zh-CN.ts b/src/i18n/locales/zh-CN.ts index 3f5c5164..34dc0636 100644 --- a/src/i18n/locales/zh-CN.ts +++ b/src/i18n/locales/zh-CN.ts @@ -136,9 +136,11 @@ const zhCN = { // ── Runtime Thread Surface (Plan Approval) ─────────────── "plan.implementAsPlan": "按计划实施", - "plan.clearAndImplement": "清理上下文后按计划实施", - "plan.approvedClearAndImplement": "已批准:清理上下文后按计划实施", + "plan.clearAndImplement": "清理上下文后实施", + "plan.goalImplement": "按计划设置Goal后实施", + "plan.approvedClearAndImplement": "已批准:清理上下文后实施", "plan.approvedImplement": "已批准:按计划实施", + "plan.approvedGoalImplement": "已批准:按计划设置Goal后实施", "plan.approvedToImplement": "已批准进入实施", "plan.superseded": "该计划已被新的规划版本替代。", "plan.awaitingApproval": "等待实施审批", diff --git a/src/modules/workbench-shell/ui/runtime-thread-surface-metadata.ts b/src/modules/workbench-shell/ui/runtime-thread-surface-metadata.ts index 41886126..f906cdd1 100644 --- a/src/modules/workbench-shell/ui/runtime-thread-surface-metadata.ts +++ b/src/modules/workbench-shell/ui/runtime-thread-surface-metadata.ts @@ -2,7 +2,7 @@ import type { TranslationKey } from "@/i18n"; -export type PlanApprovalAction = "apply_plan" | "apply_plan_with_context_reset"; +export type PlanApprovalAction = "apply_plan" | "apply_plan_with_context_reset" | "apply_plan_with_goal"; export type PlanStepMetadata = { description?: string; @@ -152,7 +152,7 @@ export function parseApprovalPromptMetadata(value: unknown, t: (key: Translation const action = readStringField(optionRecord, "action"); const label = readStringField(optionRecord, "label"); if ( - (action !== "apply_plan" && action !== "apply_plan_with_context_reset") + (action !== "apply_plan" && action !== "apply_plan_with_context_reset" && action !== "apply_plan_with_goal") || !label ) { return null; @@ -167,6 +167,7 @@ export function parseApprovalPromptMetadata(value: unknown, t: (key: Translation approvedAction: readStringField(record, "approvedAction") === "apply_plan" || readStringField(record, "approvedAction") === "apply_plan_with_context_reset" + || readStringField(record, "approvedAction") === "apply_plan_with_goal" ? (readStringField(record, "approvedAction") as PlanApprovalAction) : null, options: options.length > 0 diff --git a/src/modules/workbench-shell/ui/runtime-thread-surface-state.ts b/src/modules/workbench-shell/ui/runtime-thread-surface-state.ts index dc8a284b..5d679201 100644 --- a/src/modules/workbench-shell/ui/runtime-thread-surface-state.ts +++ b/src/modules/workbench-shell/ui/runtime-thread-surface-state.ts @@ -393,11 +393,15 @@ export function mapRecordedUserMessage(event: RecordedUserMessageEvent): Surface export function formatApprovalPromptState(state: string, approvedAction: PlanApprovalAction | null, t: (key: TranslationKey) => string) { switch (state) { case "approved": - return approvedAction === "apply_plan_with_context_reset" - ? t("plan.approvedClearAndImplement") - : approvedAction === "apply_plan" - ? t("plan.approvedImplement") - : t("plan.approvedToImplement") + if (approvedAction === "apply_plan_with_context_reset") { + return t("plan.approvedClearAndImplement"); + } + if (approvedAction === "apply_plan_with_goal") { + return t("plan.approvedGoalImplement"); + } + return approvedAction === "apply_plan" + ? t("plan.approvedImplement") + : t("plan.approvedToImplement"); case "superseded": return t("plan.superseded"); default: diff --git a/src/modules/workbench-shell/ui/runtime-thread-surface.tsx b/src/modules/workbench-shell/ui/runtime-thread-surface.tsx index b53b4eb9..192d7bd4 100644 --- a/src/modules/workbench-shell/ui/runtime-thread-surface.tsx +++ b/src/modules/workbench-shell/ui/runtime-thread-surface.tsx @@ -2238,7 +2238,7 @@ export function RuntimeThreadSurface({ } preserveContextUsageOnNextEmptySnapshotRef.current = action === "apply_plan"; - if (action === "apply_plan_with_context_reset") { + if (action === "apply_plan_with_context_reset" || action === "apply_plan_with_goal") { threadStore.setState({ runtimeContextUsage: null }); } setApprovingPlanMessageId(messageId); diff --git a/src/services/bridge/agent-commands.ts b/src/services/bridge/agent-commands.ts index 9159bc83..9485629b 100644 --- a/src/services/bridge/agent-commands.ts +++ b/src/services/bridge/agent-commands.ts @@ -606,7 +606,7 @@ export async function threadSubscribeRun( export async function threadExecuteApprovedPlan( threadId: string, approvalMessageId: string, - action: "apply_plan" | "apply_plan_with_context_reset", + action: "apply_plan" | "apply_plan_with_context_reset" | "apply_plan_with_goal", onEvent: (event: ThreadStreamEvent) => void, ): Promise { requireTauri("thread_execute_approved_plan"); diff --git a/src/services/thread-stream/thread-stream.ts b/src/services/thread-stream/thread-stream.ts index 96ae4833..9a717478 100644 --- a/src/services/thread-stream/thread-stream.ts +++ b/src/services/thread-stream/thread-stream.ts @@ -357,7 +357,7 @@ export class ThreadStream { async executeApprovedPlan( threadId: string, approvalMessageId: string, - action: "apply_plan" | "apply_plan_with_context_reset", + action: "apply_plan" | "apply_plan_with_context_reset" | "apply_plan_with_goal", ): Promise { try { const runId = await threadExecuteApprovedPlan( From 553cba5996722eec3ce9405605784fbbaa8a9a81 Mon Sep 17 00:00:00 2001 From: Jorben Date: Fri, 19 Jun 2026 00:27:31 +0800 Subject: [PATCH 2/2] =?UTF-8?q?test(agent-run):=20=E2=9C=85=20add=20tests?= =?UTF-8?q?=20for=20ApplyPlanWithGoal=20goal-creation=20and=20goal-exists?= =?UTF-8?q?=20approval=20branch?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src-tauri/src/core/plan_checkpoint.rs | 83 +++++++++++ src-tauri/tests/agent_run.rs | 196 ++++++++++++++++++++++++++ 2 files changed, 279 insertions(+) diff --git a/src-tauri/src/core/plan_checkpoint.rs b/src-tauri/src/core/plan_checkpoint.rs index a9c572eb..e3ab8249 100644 --- a/src-tauri/src/core/plan_checkpoint.rs +++ b/src-tauri/src/core/plan_checkpoint.rs @@ -541,6 +541,89 @@ mod tests { assert!(approval_prompt_markdown(&artifact).contains("Implement checkpoint")); } + #[tokio::test] + async fn build_approval_prompt_metadata_suppresses_goal_option_when_goal_exists() { + use crate::model::goal::{GoalRecord, GoalStatus}; + use crate::persistence::repo::goal_repo; + use chrono::Utc; + + let pool = setup_test_pool().await; + + // Seed workspace + thread to satisfy the goals → threads FK. + let now = Utc::now().to_rfc3339(); + sqlx::query( + "INSERT INTO workspaces (id, name, path, canonical_path, display_path, + is_default, is_git, auto_work_tree, status, created_at, updated_at) + VALUES ('ws-goal', 'Test', '/tmp/test', '/tmp/test', '/tmp/test', + 0, 0, 0, 'ready', ?, ?)", + ) + .bind(&now) + .bind(&now) + .execute(&pool) + .await + .expect("seed workspace"); + + sqlx::query( + "INSERT INTO threads (id, workspace_id, title, status, last_active_at, created_at, updated_at) + VALUES ('thread-with-goal', 'ws-goal', 'Test', 'idle', ?, ?, ?)", + ) + .bind(&now) + .bind(&now) + .bind(&now) + .execute(&pool) + .await + .expect("seed thread"); + + // Insert an active goal for this thread. + let goal = GoalRecord { + id: "goal-1".to_string(), + thread_id: "thread-with-goal".to_string(), + objective: "Build feature X".to_string(), + status: GoalStatus::Active, + token_budget: None, + tokens_used: 0, + turns_used: 0, + max_turns: 50, + pause_reason: None, + pause_detail: None, + evidence: None, + last_evaluated_run_id: None, + judge_passed: false, + judge_completeness: None, + judge_findings: None, + judge_summary: None, + judge_evaluated_run_id: None, + created_at: Utc::now(), + updated_at: Utc::now(), + }; + goal_repo::insert(&pool, &goal).await.expect("insert goal"); + + let artifact = build_plan_artifact_from_tool_input( + &serde_json::json!({"title": "Test", "summary": "Test plan."}), + 1, + ); + let approval = build_approval_prompt_metadata( + &pool, + "thread-with-goal", + artifact.plan_revision, + "msg-plan", + ) + .await; + + // When a goal already exists, only 2 options should be returned + // (ApplyPlan and ApplyPlanWithContextReset), excluding ApplyPlanWithGoal. + assert_eq!(approval.options.len(), 2); + assert_eq!(approval.options[0].action, PlanApprovalAction::ApplyPlan); + assert_eq!( + approval.options[1].action, + PlanApprovalAction::ApplyPlanWithContextReset + ); + assert!(!approval + .options + .iter() + .any(|o| o.action == PlanApprovalAction::ApplyPlanWithGoal)); + } + #[test] fn plan_markdown_renders_new_structured_sections() { let artifact = build_plan_artifact_from_tool_input( diff --git a/src-tauri/tests/agent_run.rs b/src-tauri/tests/agent_run.rs index f613da26..49f53b55 100644 --- a/src-tauri/tests/agent_run.rs +++ b/src-tauri/tests/agent_run.rs @@ -1121,3 +1121,199 @@ async fn test_render_validation_failure_persists_failed_tool_call() { .unwrap() .contains("valid 'spec' object")); } + +// ========================================================================= +// ApplyPlanWithGoal: goal-creation integration test +// ========================================================================= + +#[tokio::test] +async fn test_execute_approved_plan_with_goal_creates_goal_record() { + use std::sync::{Arc, Mutex as StdMutex}; + use tiycode_lib::core::agent_run_manager::AgentRunManager; + use tiycode_lib::core::app_event_emitter::NoopAppEventEmitter; + use tiycode_lib::core::app_state::GoalRuntimeState; + use tiycode_lib::core::built_in_agent_runtime::BuiltInAgentRuntime; + use tiycode_lib::core::plan_checkpoint::{ + build_plan_artifact_from_tool_input, build_plan_message_metadata, ApprovalPromptMetadata, + PlanApprovalAction, PlanApprovalOption, IMPLEMENTATION_PLAN_APPROVAL_KIND, + IMPLEMENTATION_PLAN_PENDING_STATE, + }; + use tiycode_lib::core::sleep_manager::SleepManager; + use tiycode_lib::core::terminal_manager::TerminalManager; + use tiycode_lib::core::tool_gateway::ToolGateway; + use tiycode_lib::model::thread::MessageRecord; + use tiycode_lib::persistence::repo::{goal_repo, message_repo, run_repo}; + + let pool = test_helpers::setup_test_pool().await; + test_helpers::seed_workspace(&pool, "ws-goal-plan", "/tmp/goal-plan").await; + test_helpers::seed_thread(&pool, "t-goal-plan", "ws-goal-plan", None).await; + + // Insert a provider so build_session_spec can resolve the model plan. + sqlx::query( + "INSERT INTO providers ( + id, provider_kind, provider_key, name, protocol_type, base_url, + api_key_encrypted, enabled, mapping_locked + ) VALUES ('prov-goal', 'builtin', 'openai', 'OpenAI', 'openai', + 'https://api.openai.com/v1', 'sk-test', 1, 1)", + ) + .execute(&pool) + .await + .unwrap(); + + // Model plan JSON matching the provider we just seeded. + let model_plan = serde_json::json!({ + "primary": { + "providerId": "prov-goal", + "modelRecordId": "model-goal", + "providerType": "openai", + "providerName": "OpenAI", + "model": "gpt-4.1", + "modelId": "gpt-4.1", + "modelDisplayName": "GPT-4.1", + "baseUrl": "https://api.openai.com/v1", + "contextWindow": "128000", + "maxOutputTokens": "16384" + } + }); + + // Create the planning run with the model plan. + run_repo::insert( + &pool, + &run_repo::RunInsert { + id: "r-planning".to_string(), + thread_id: "t-goal-plan".to_string(), + profile_id: None, + run_mode: "plan".to_string(), + provider_id: None, + model_id: None, + effective_model_plan_json: Some(model_plan.to_string()), + status: "waiting_approval".to_string(), + }, + ) + .await + .unwrap(); + + // Create the plan message with PlanMessageMetadata. + let artifact = build_plan_artifact_from_tool_input( + &serde_json::json!({ + "title": "Goal-driven plan test", + "summary": "Verify ApplyPlanWithGoal creates a goal." + }), + 1, + ); + let plan_metadata = build_plan_message_metadata(artifact.clone(), "r-planning", "plan"); + let plan_message = MessageRecord { + id: "m-plan".to_string(), + thread_id: "t-goal-plan".to_string(), + run_id: Some("r-planning".to_string()), + role: "assistant".to_string(), + content_markdown: "# Goal-driven plan test".to_string(), + parts_json: None, + message_type: "plan".to_string(), + status: "completed".to_string(), + metadata_json: serde_json::to_string(&plan_metadata).ok(), + attachments_json: None, + created_at: String::new(), + }; + message_repo::insert(&pool, &plan_message).await.unwrap(); + + // Create the approval_prompt message with ApprovalPromptMetadata (pending). + let approval_metadata = ApprovalPromptMetadata { + kind: IMPLEMENTATION_PLAN_APPROVAL_KIND.to_string(), + plan_revision: 1, + plan_message_id: "m-plan".to_string(), + state: IMPLEMENTATION_PLAN_PENDING_STATE.to_string(), + options: vec![ + PlanApprovalOption { + action: PlanApprovalAction::ApplyPlan, + label: PlanApprovalAction::ApplyPlan.label().to_string(), + }, + PlanApprovalOption { + action: PlanApprovalAction::ApplyPlanWithContextReset, + label: PlanApprovalAction::ApplyPlanWithContextReset + .label() + .to_string(), + }, + PlanApprovalOption { + action: PlanApprovalAction::ApplyPlanWithGoal, + label: PlanApprovalAction::ApplyPlanWithGoal.label().to_string(), + }, + ], + expires_on_new_user_message: true, + approved_action: None, + }; + let approval_message = MessageRecord { + id: "m-approval".to_string(), + thread_id: "t-goal-plan".to_string(), + run_id: Some("r-planning".to_string()), + role: "assistant".to_string(), + content_markdown: "Plan approval prompt".to_string(), + parts_json: None, + message_type: "approval_prompt".to_string(), + status: "completed".to_string(), + metadata_json: serde_json::to_string(&approval_metadata).ok(), + attachments_json: None, + created_at: String::new(), + }; + message_repo::insert(&pool, &approval_message) + .await + .unwrap(); + + // Build AgentRunManager with real dependencies. + let goal_runtime_state = Arc::new(StdMutex::new(GoalRuntimeState::default())); + let terminal_manager = Arc::new(TerminalManager::new(pool.clone())); + let tool_gateway = Arc::new(ToolGateway::new( + pool.clone(), + Arc::clone(&terminal_manager), + )); + let runtime = Arc::new(BuiltInAgentRuntime::new( + pool.clone(), + Arc::clone(&tool_gateway), + Arc::clone(&goal_runtime_state), + )); + let sleep_manager = Arc::new(SleepManager::new()); + let app_events: Arc = + Arc::new(NoopAppEventEmitter); + let manager = Arc::new(AgentRunManager::new( + pool.clone(), + app_events, + runtime, + sleep_manager, + goal_runtime_state, + )); + + // Execute the approved plan with ApplyPlanWithGoal. + let result = manager + .execute_approved_plan( + "t-goal-plan", + "m-approval", + PlanApprovalAction::ApplyPlanWithGoal, + ) + .await; + + // The run should start successfully (the LLM call happens asynchronously). + // Even if it fails, the goal record should already be persisted. + if result.is_ok() { + // Clean up: cancel the spawned run task to avoid background panics. + let _ = manager.cancel_run("t-goal-plan").await; + } + + // Verify that a goal record was created in the database. + let goal = goal_repo::find_by_thread_id(&pool, "t-goal-plan") + .await + .expect("query goals"); + assert!( + goal.is_some(), + "ApplyPlanWithGoal should create a goal record; execute_approved_plan returned: {:?}", + result.as_ref().err() + ); + + let goal = goal.unwrap(); + assert_eq!(goal.thread_id, "t-goal-plan"); + assert_eq!(goal.status, tiycode_lib::model::goal::GoalStatus::Active); + assert!( + goal.objective.contains("implementation plan"), + "goal objective should reference the plan; got: {}", + goal.objective + ); +}