Skip to content
Merged
Show file tree
Hide file tree
Changes from 1 commit
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions src-tauri/src/commands/agent.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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,
Expand Down
31 changes: 28 additions & 3 deletions src-tauri/src/core/agent_run_manager.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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!(

This comment was marked as outdated.

"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?;
Expand All @@ -454,6 +466,19 @@ impl AgentRunManager {
}
};

// For ApplyPlanWithGoal: create a persistent goal before starting the

This comment was marked as outdated.

// implementation run so the goal continuation loop can drive execution.
if let PlanApprovalAction::ApplyPlanWithGoal = action {

This comment was marked as outdated.

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,
Expand Down
17 changes: 11 additions & 6 deletions src-tauri/src/core/agent_run_summary.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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())
Expand All @@ -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,
)
}
}
}

Expand Down
9 changes: 7 additions & 2 deletions src-tauri/src/core/agent_session_execution.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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(),
Expand Down
81 changes: 63 additions & 18 deletions src-tauri/src/core/plan_checkpoint.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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后实施",
}
}
}
Expand Down Expand Up @@ -103,27 +105,44 @@ pub fn build_plan_message_metadata(
}
}

pub fn build_approval_prompt_metadata(
pub async fn build_approval_prompt_metadata(

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[MEDIUM] New async DB query on hot path without early return

Every plan approval prompt now performs an additional async database query to check for existing goals. While this is a lightweight lookup, it adds latency to the approval flow and could be avoided by caching or deferring the check.

Suggestion: Consider whether the goal-option visibility check can be performed only when the plan message is first built, or cache the existence of goals for the thread in memory. Alternatively, rely on the frontend to always send 'apply_plan_with_goal' and validate server-side only when the action is actually invoked.

Risk: Slight increase in plan approval prompt construction latency (~1-5 ms per DB roundtrip). Not significant for interactive use but adds up under concurrent load.

Confidence: 0.85

[From SubAgent: performance]

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)

This comment was marked as outdated.

.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,
}
Expand Down Expand Up @@ -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() {
Expand Down Expand Up @@ -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",
Expand All @@ -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
Expand Down
2 changes: 2 additions & 0 deletions src/i18n/locales/en.ts
Original file line number Diff line number Diff line change
Expand Up @@ -138,8 +138,10 @@ const en: Record<TranslationKey, string> = {
// ── 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",
Expand Down
6 changes: 4 additions & 2 deletions src/i18n/locales/zh-CN.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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": "等待实施审批",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -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")

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

[LOW] Frontend data-path guard could miss new action values

The frontend validation regex is duplicated in three places within the same function (option action parsing, and two approvedAction checks), making it easy to miss one when adding new actions in the future.

Suggestion: Extract the valid action list into a constant set or array (e.g., const VALID_ACTIONS = new Set(['apply_plan', 'apply_plan_with_context_reset', 'apply_plan_with_goal'])) and use it for all validation points.

Risk: Future additions of another action could accidentally skip one of the validation branches, resulting in inconsistent behavior where options are allowed but approved actions are rejected or vice versa.

Confidence: 0.85

[From SubAgent: general]

|| !label
) {
return null;
Expand All @@ -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
Expand Down
14 changes: 9 additions & 5 deletions src/modules/workbench-shell/ui/runtime-thread-surface-state.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 1 addition & 1 deletion src/modules/workbench-shell/ui/runtime-thread-surface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
2 changes: 1 addition & 1 deletion src/services/bridge/agent-commands.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
requireTauri("thread_execute_approved_plan");
Expand Down
2 changes: 1 addition & 1 deletion src/services/thread-stream/thread-stream.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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<string> {
try {
const runId = await threadExecuteApprovedPlan(
Expand Down
Loading