返回 CodeWhale
goal.rs
根目录 / crates / tui / src / tools / goal.rs
1 //! Goal tools for the model-visible LLM-as-judge loop.
2 //!
3 //! The TUI already has a `/goal` command and passes its objective into the
4 //! engine prompt. This module keeps the runtime slice separate: a small
5 //! session-scoped state object plus tools the model can use to inspect and
6 //! close out that state.
7
8 use std::sync::{Arc, Mutex};
9 use std::time::Instant;
10
11 use async_trait::async_trait;
12 use serde::{Deserialize, Serialize};
13 use serde_json::{Value, json};
14 use sha2::{Digest, Sha256};
15
16 use crate::tools::spec::{
17 ApprovalRequirement, ToolCapability, ToolContext, ToolError, ToolResult, ToolSpec, required_str,
18 };
19
20 /// Maximum number of automatic goal-continuation prompt injections in one
21 /// engine turn. This is intra-turn granularity only — it prevents a stuck spin
22 /// within a single turn from making no progress. The cross-turn loop has its
23 /// own conservative circuit breaker; see `goal_loop::decide_continuation`.
24 pub const MAX_GOAL_CONTINUATIONS_PER_TURN: u32 = 3;
25
26 /// Identical critical verifier gap sets required before automatic
27 /// continuation pauses for inspection.
28 pub const NO_PROGRESS_STALL_THRESHOLD: u32 = 3;
29
30 /// Shared reference to the current runtime goal.
31 pub type SharedGoalState = Arc<Mutex<GoalState>>;
32
33 /// Create an empty shared goal state.
34 #[must_use]
35 pub fn new_shared_goal_state() -> SharedGoalState {
36 Arc::new(Mutex::new(GoalState::default()))
37 }
38
39 /// Create shared state seeded from the host goal surface with an explicit status.
40 #[must_use]
41 pub fn new_shared_goal_state_from_host_status(
42 objective: Option<String>,
43 token_budget: Option<u32>,
44 status: GoalStatus,
45 ) -> SharedGoalState {
46 let mut state = GoalState::default();
47 state.sync_from_host_status(objective.as_deref(), token_budget, status);
48 Arc::new(Mutex::new(state))
49 }
50
51 /// Runtime status for a goal.
52 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
53 pub enum GoalStatus {
54 Active,
55 Paused,
56 Complete,
57 Blocked,
58 }
59
60 impl GoalStatus {
61 #[must_use]
62 pub fn as_str(self) -> &'static str {
63 match self {
64 Self::Active => "active",
65 Self::Paused => "paused",
66 Self::Complete => "complete",
67 Self::Blocked => "blocked",
68 }
69 }
70 }
71
72 /// Why an otherwise unfinished goal is paused.
73 #[derive(Debug, Clone, Copy, Serialize, PartialEq, Eq)]
74 #[serde(rename_all = "snake_case")]
75 pub enum GoalPauseReason {
76 User,
77 Backoff,
78 NoProgress,
79 UsageLimit,
80 BudgetLimit,
81 }
82
83 /// Whether a goal review is allowed to decide the judged contract.
84 ///
85 /// Critical reviews fail closed and may satisfy the completion gate. Advisory
86 /// reviews are append-only context: malformed or negative advice must never
87 /// pause, block, or complete the goal.
88 #[derive(Debug, Clone, Copy, Default, Serialize, Deserialize, PartialEq, Eq)]
89 #[serde(rename_all = "snake_case")]
90 pub enum GoalReviewRole {
91 #[default]
92 Critical,
93 Advisory,
94 }
95
96 /// Best-effort review context kept separate from the judged completion
97 /// contract. Notes are append-only for the lifetime of one objective.
98 #[derive(Debug, Clone, Serialize, PartialEq, Eq)]
99 pub struct GoalAdvisoryNote {
100 pub summary: String,
101 }
102
103 impl GoalPauseReason {
104 #[must_use]
105 pub fn label(self) -> &'static str {
106 match self {
107 Self::User => "user",
108 Self::Backoff => "run limit",
109 Self::NoProgress => "no progress",
110 Self::UsageLimit => "usage limit",
111 Self::BudgetLimit => "budget limit",
112 }
113 }
114 }
115
116 /// Session-local goal state. `Instant` stays runtime-only; snapshots expose
117 /// elapsed seconds so tool output remains serializable and stable.
118 #[derive(Debug, Clone, Default)]
119 pub struct GoalState {
120 objective: Option<String>,
121 token_budget: Option<u32>,
122 status: Option<GoalStatus>,
123 tokens_used: u64,
124 time_used_seconds: u64,
125 continuation_count: u32,
126 started_at: Option<Instant>,
127 finished_at: Option<Instant>,
128 evidence: Option<String>,
129 blocker: Option<String>,
130 pause_reason: Option<GoalPauseReason>,
131 completion_verification: Option<GoalCompletionVerification>,
132 advisories: Vec<GoalAdvisoryNote>,
133 last_gap_fingerprint: Option<String>,
134 repeated_gap_count: u32,
135 }
136
137 impl GoalState {
138 #[must_use]
139 pub fn objective(&self) -> Option<&str> {
140 self.objective.as_deref()
141 }
142
143 #[must_use]
144 pub fn token_budget(&self) -> Option<u32> {
145 self.token_budget
146 }
147
148 #[must_use]
149 pub fn is_active(&self) -> bool {
150 self.objective.is_some() && self.status == Some(GoalStatus::Active)
151 }
152
153 pub fn sync_from_host_status(
154 &mut self,
155 objective: Option<&str>,
156 token_budget: Option<u32>,
157 status: GoalStatus,
158 ) {
159 let objective = objective.map(str::trim).filter(|value| !value.is_empty());
160 match objective {
161 Some(objective) => {
162 let changed = self.objective.as_deref() != Some(objective);
163 let status_changed = self.status != Some(status);
164 let resumed = !changed
165 && status == GoalStatus::Active
166 && self
167 .status
168 .is_some_and(|previous| previous != GoalStatus::Active);
169 if changed {
170 self.objective = Some(objective.to_string());
171 self.token_budget = token_budget;
172 self.tokens_used = 0;
173 self.time_used_seconds = 0;
174 self.continuation_count = 0;
175 self.started_at = Some(Instant::now());
176 self.evidence = None;
177 self.blocker = None;
178 self.pause_reason = None;
179 self.completion_verification = None;
180 self.advisories.clear();
181 self.last_gap_fingerprint = None;
182 self.repeated_gap_count = 0;
183 } else if self.token_budget != token_budget {
184 self.token_budget = token_budget;
185 }
186
187 if resumed {
188 self.evidence = None;
189 self.blocker = None;
190 self.pause_reason = None;
191 self.completion_verification = None;
192 self.last_gap_fingerprint = None;
193 self.repeated_gap_count = 0;
194 }
195
196 if changed || status_changed || self.status.is_none() {
197 self.status = Some(status);
198 self.pause_reason = if status == GoalStatus::Paused {
199 Some(GoalPauseReason::User)
200 } else {
201 None
202 };
203 self.finished_at = if status == GoalStatus::Active {
204 None
205 } else {
206 Some(Instant::now())
207 };
208 }
209 }
210 None => self.clear(),
211 }
212 }
213
214 pub fn create(
215 &mut self,
216 objective: String,
217 token_budget: Option<u32>,
218 ) -> Result<(), &'static str> {
219 if self.objective.is_some() && self.status != Some(GoalStatus::Complete) {
220 return Err(
221 "An unfinished goal already exists. Complete or clear it before creating another.",
222 );
223 }
224 self.objective = Some(objective);
225 self.token_budget = token_budget;
226 self.status = Some(GoalStatus::Active);
227 self.tokens_used = 0;
228 self.time_used_seconds = 0;
229 self.continuation_count = 0;
230 self.started_at = Some(Instant::now());
231 self.finished_at = None;
232 self.evidence = None;
233 self.blocker = None;
234 self.pause_reason = None;
235 self.completion_verification = None;
236 self.advisories.clear();
237 self.last_gap_fingerprint = None;
238 self.repeated_gap_count = 0;
239 Ok(())
240 }
241
242 pub fn record_usage(&mut self, token_delta: u64, time_delta_seconds: u64) {
243 if self.is_active() {
244 self.tokens_used = self.tokens_used.saturating_add(token_delta);
245 self.time_used_seconds = self.time_used_seconds.saturating_add(time_delta_seconds);
246 }
247 }
248
249 pub fn record_continuation(&mut self) {
250 if self.is_active() {
251 self.continuation_count = self.continuation_count.saturating_add(1);
252 }
253 }
254
255 pub fn mark_complete(
256 &mut self,
257 evidence: String,
258 mut verification: GoalCompletionVerification,
259 ) -> Result<(), &'static str> {
260 if self.objective.is_none() {
261 return Err("No active goal exists to complete.");
262 }
263 if self.status == Some(GoalStatus::Complete) || self.completion_verification.is_some() {
264 return Err("The judged completion contract is already sealed and cannot be replaced.");
265 }
266 if verification.role != GoalReviewRole::Critical {
267 return Err("An advisory review cannot complete the judged goal contract.");
268 }
269 verification.contract_fingerprint = completion_contract_fingerprint(
270 self.objective.as_deref().unwrap_or_default(),
271 &verification,
272 );
273 self.status = Some(GoalStatus::Complete);
274 self.finished_at = Some(Instant::now());
275 self.evidence = Some(evidence);
276 self.blocker = None;
277 self.pause_reason = None;
278 self.completion_verification = Some(verification);
279 Ok(())
280 }
281
282 pub fn record_advisory(&mut self, summary: String) -> Result<(), &'static str> {
283 if !self.is_active() {
284 return Err("Advisory notes require an active goal.");
285 }
286 const MAX_ADVISORY_NOTES: usize = 16;
287 if self.advisories.len() == MAX_ADVISORY_NOTES {
288 self.advisories.remove(0);
289 }
290 self.advisories.push(GoalAdvisoryNote { summary });
291 Ok(())
292 }
293
294 pub fn record_not_achieved(
295 &mut self,
296 verification: GoalProgressVerification,
297 ) -> Result<(), &'static str> {
298 if !self.is_active() {
299 return Err("Verifier progress requires an active goal.");
300 }
301 if verification.role == GoalReviewRole::Advisory {
302 return self
303 .record_advisory(format!("{}: {}", verification.check, verification.summary));
304 }
305
306 let fingerprint = gap_fingerprint(&verification.gaps)
307 .ok_or("Critical not-achieved verification requires at least one concrete gap.")?;
308 self.repeated_gap_count = if self.last_gap_fingerprint.as_deref() == Some(&fingerprint) {
309 self.repeated_gap_count.saturating_add(1)
310 } else {
311 1
312 };
313 self.last_gap_fingerprint = Some(fingerprint);
314
315 if self.repeated_gap_count >= NO_PROGRESS_STALL_THRESHOLD {
316 self.status = Some(GoalStatus::Paused);
317 self.finished_at = Some(Instant::now());
318 self.pause_reason = Some(GoalPauseReason::NoProgress);
319 self.evidence = None;
320 self.blocker = None;
321 self.completion_verification = None;
322 }
323 Ok(())
324 }
325
326 pub fn mark_blocked(&mut self, blocker: String) -> Result<(), &'static str> {
327 if self.objective.is_none() {
328 return Err("No active goal exists to block.");
329 }
330 self.status = Some(GoalStatus::Blocked);
331 self.finished_at = Some(Instant::now());
332 self.blocker = Some(blocker);
333 self.evidence = None;
334 self.pause_reason = None;
335 self.completion_verification = None;
336 Ok(())
337 }
338
339 pub fn mark_paused(&mut self, reason: GoalPauseReason) -> Result<(), &'static str> {
340 if self.objective.is_none() {
341 return Err("No active goal exists to pause.");
342 }
343 self.status = Some(GoalStatus::Paused);
344 self.finished_at = Some(Instant::now());
345 self.pause_reason = Some(reason);
346 self.evidence = None;
347 self.blocker = None;
348 self.completion_verification = None;
349 Ok(())
350 }
351
352 pub fn clear(&mut self) {
353 *self = Self::default();
354 }
355
356 #[must_use]
357 pub fn snapshot(&self) -> GoalSnapshot {
358 // Once the goal is terminal, freeze elapsed at the finish time so the
359 // sidebar timer (and any tool snapshot) stops growing after completion.
360 let elapsed_seconds = match (self.started_at, self.finished_at) {
361 (Some(started), Some(finished)) => {
362 Some(finished.saturating_duration_since(started).as_secs())
363 }
364 (Some(started), None) => Some(started.elapsed().as_secs()),
365 (None, _) => None,
366 };
367 GoalSnapshot {
368 objective: self.objective.clone(),
369 status: self
370 .status
371 .map(GoalStatus::as_str)
372 .unwrap_or("none")
373 .to_string(),
374 token_budget: self.token_budget,
375 tokens_used: self.tokens_used,
376 time_used_seconds: self.time_used_seconds,
377 continuation_count: self.continuation_count,
378 elapsed_seconds,
379 evidence: self.evidence.clone(),
380 blocker: self.blocker.clone(),
381 pause_reason: self.pause_reason,
382 completion_verification: self.completion_verification.clone(),
383 advisories: self.advisories.clone(),
384 last_gap_fingerprint: self.last_gap_fingerprint.clone(),
385 repeated_gap_count: self.repeated_gap_count,
386 }
387 }
388 }
389
390 /// Serializable tool output and prompt input for the current goal.
391 #[derive(Debug, Clone, Default, Serialize, PartialEq, Eq)]
392 pub struct GoalSnapshot {
393 pub objective: Option<String>,
394 pub status: String,
395 pub token_budget: Option<u32>,
396 pub tokens_used: u64,
397 pub time_used_seconds: u64,
398 pub continuation_count: u32,
399 pub elapsed_seconds: Option<u64>,
400 pub evidence: Option<String>,
401 pub blocker: Option<String>,
402 pub pause_reason: Option<GoalPauseReason>,
403 pub completion_verification: Option<GoalCompletionVerification>,
404 pub advisories: Vec<GoalAdvisoryNote>,
405 pub last_gap_fingerprint: Option<String>,
406 pub repeated_gap_count: u32,
407 }
408
409 #[derive(Debug, Clone, Default, Serialize, Deserialize, PartialEq, Eq)]
410 pub struct GoalCompletionVerification {
411 pub status: String,
412 pub check: String,
413 pub summary: String,
414 #[serde(default)]
415 pub role: GoalReviewRole,
416 #[serde(default)]
417 pub contract_fingerprint: String,
418 }
419
420 #[derive(Debug, Clone, Deserialize, PartialEq, Eq)]
421 pub struct GoalProgressVerification {
422 pub status: String,
423 pub check: String,
424 pub summary: String,
425 #[serde(default)]
426 pub role: GoalReviewRole,
427 #[serde(default)]
428 pub gaps: Vec<String>,
429 }
430
431 fn completion_contract_fingerprint(
432 objective: &str,
433 verification: &GoalCompletionVerification,
434 ) -> String {
435 let mut hasher = Sha256::new();
436 for field in [
437 objective.trim(),
438 verification.status.trim(),
439 verification.check.trim(),
440 verification.summary.trim(),
441 ] {
442 hasher.update(field.as_bytes());
443 hasher.update([0]);
444 }
445 hasher
446 .finalize()
447 .iter()
448 .map(|byte| format!("{byte:02x}"))
449 .collect()
450 }
451
452 fn gap_fingerprint(gaps: &[String]) -> Option<String> {
453 let mut normalized = gaps
454 .iter()
455 .map(|gap| {
456 gap.split_whitespace()
457 .collect::<Vec<_>>()
458 .join(" ")
459 .to_lowercase()
460 })
461 .filter(|gap| !gap.is_empty())
462 .collect::<Vec<_>>();
463 normalized.sort_unstable();
464 normalized.dedup();
465 if normalized.is_empty() {
466 return None;
467 }
468
469 let mut hasher = Sha256::new();
470 hasher.update(b"codewhale-goal-gaps-v1\0");
471 for gap in normalized {
472 hasher.update(gap.as_bytes());
473 hasher.update([0]);
474 }
475 Some(
476 hasher
477 .finalize()
478 .iter()
479 .map(|byte| format!("{byte:02x}"))
480 .collect(),
481 )
482 }
483
484 impl GoalSnapshot {
485 #[must_use]
486 pub fn is_active(&self) -> bool {
487 self.objective.is_some() && self.status == GoalStatus::Active.as_str()
488 }
489
490 #[must_use]
491 pub fn from_thread_goal(goal: &codewhale_protocol::ThreadGoal) -> Self {
492 let (status, pause_reason) = thread_goal_status_projection(goal.status.clone());
493 Self {
494 objective: Some(goal.objective.clone()),
495 status: status.as_str().to_string(),
496 token_budget: goal
497 .token_budget
498 .and_then(|value| u32::try_from(value.max(0)).ok()),
499 tokens_used: u64::try_from(goal.tokens_used.max(0)).unwrap_or(u64::MAX),
500 time_used_seconds: u64::try_from(goal.time_used_seconds.max(0)).unwrap_or(u64::MAX),
501 continuation_count: u32::try_from(goal.continuation_count.max(0)).unwrap_or(u32::MAX),
502 elapsed_seconds: None,
503 evidence: None,
504 blocker: None,
505 pause_reason,
506 completion_verification: None,
507 advisories: Vec::new(),
508 last_gap_fingerprint: None,
509 repeated_gap_count: 0,
510 }
511 }
512 }
513
514 #[must_use]
515 pub fn thread_goal_status_projection(
516 status: codewhale_protocol::ThreadGoalStatus,
517 ) -> (GoalStatus, Option<GoalPauseReason>) {
518 match status {
519 codewhale_protocol::ThreadGoalStatus::Active => (GoalStatus::Active, None),
520 codewhale_protocol::ThreadGoalStatus::Paused => {
521 (GoalStatus::Paused, Some(GoalPauseReason::User))
522 }
523 codewhale_protocol::ThreadGoalStatus::Complete => (GoalStatus::Complete, None),
524 codewhale_protocol::ThreadGoalStatus::Blocked => (GoalStatus::Blocked, None),
525 codewhale_protocol::ThreadGoalStatus::UsageLimited => {
526 (GoalStatus::Paused, Some(GoalPauseReason::UsageLimit))
527 }
528 codewhale_protocol::ThreadGoalStatus::BudgetLimited => {
529 (GoalStatus::Paused, Some(GoalPauseReason::BudgetLimit))
530 }
531 }
532 }
533
534 /// Render the continuation prompt injected when a goal is still active after a
535 /// turn. This shows progress and lets the circuit breaker remain an
536 /// implementation detail rather than encouraging the model to spend the cap.
537 #[must_use]
538 pub fn render_continuation_prompt(snapshot: &GoalSnapshot, continuation_index: u32) -> String {
539 let goal_json = serde_json::to_string_pretty(snapshot).unwrap_or_else(|_| "{}".to_string());
540 format!(
541 "{}\n\n## Active Goal State\n\n```json\n{}\n```\n\nContinuation pass #{}.\nIf a critical verifier finds remaining work, call `update_goal` with `status: \"not_achieved\"` and its concrete `verification.gaps`; repeated equivalent gap sets pause the loop for inspection instead of spending indefinitely. If the goal is complete, first run or cite a concrete verifier/check when one applies, then call `update_goal` with `status: \"complete\"`, concrete evidence, and `verification: {{\"status\":\"passed\",\"check\":\"...\",\"summary\":\"...\"}}`. For non-verifiable work (docs, research, writing), use `verification: {{\"status\":\"not_applicable\",\"check\":\"...\",\"summary\":\"...\"}}` with a clear rationale instead of fabricating a verifier receipt. If it is blocked, call `update_goal` with `status: \"blocked\"` and the blocker. Otherwise continue making progress toward the objective.",
542 crate::prompts::GOAL_CONTINUATION_PROMPT.trim(),
543 goal_json,
544 continuation_index,
545 )
546 }
547
548 fn lock_goal_state(
549 state: &SharedGoalState,
550 ) -> Result<std::sync::MutexGuard<'_, GoalState>, ToolError> {
551 state
552 .lock()
553 .map_err(|_| ToolError::execution_failed("goal state lock poisoned"))
554 }
555
556 fn parse_token_budget(input: &Value) -> Result<Option<u32>, ToolError> {
557 let Some(raw) = input.get("token_budget") else {
558 return Ok(None);
559 };
560 if raw.is_null() {
561 return Ok(None);
562 }
563 let Some(value) = raw.as_u64() else {
564 return Err(ToolError::invalid_input(
565 "token_budget must be a non-negative integer",
566 ));
567 };
568 u32::try_from(value)
569 .map(Some)
570 .map_err(|_| ToolError::invalid_input("token_budget is too large"))
571 }
572
573 fn parse_completion_verification(input: &Value) -> Result<GoalCompletionVerification, ToolError> {
574 let Some(raw) = input.get("verification") else {
575 return Err(ToolError::invalid_input(
576 "verification is required when status is complete; run a verifier/check and pass verification: {status, check, summary}",
577 ));
578 };
579 let verification: GoalCompletionVerification = serde_json::from_value(raw.clone())
580 .map_err(|err| ToolError::invalid_input(format!("invalid verification: {err}")))?;
581 let status = verification.status.trim();
582 let normalized_status = match status {
583 "passed" | "not_applicable" => status,
584 other => {
585 return Err(ToolError::invalid_input(format!(
586 "verification.status must be 'passed' or 'not_applicable' before update_goal can mark a goal complete; got '{other}'"
587 )));
588 }
589 };
590 if verification.check.trim().is_empty() {
591 return Err(ToolError::invalid_input("verification.check is required"));
592 }
593 if verification.summary.trim().is_empty() {
594 return Err(ToolError::invalid_input("verification.summary is required"));
595 }
596 Ok(GoalCompletionVerification {
597 status: normalized_status.to_string(),
598 check: verification.check.trim().to_string(),
599 summary: verification.summary.trim().to_string(),
600 role: verification.role,
601 contract_fingerprint: String::new(),
602 })
603 }
604
605 fn parse_progress_verification(input: &Value) -> Result<GoalProgressVerification, ToolError> {
606 let Some(raw) = input.get("verification") else {
607 return Err(ToolError::invalid_input(
608 "verification is required when status is not_achieved",
609 ));
610 };
611 let mut verification: GoalProgressVerification = serde_json::from_value(raw.clone())
612 .map_err(|err| ToolError::invalid_input(format!("invalid verification: {err}")))?;
613 if verification.status.trim() != "not_achieved" {
614 return Err(ToolError::invalid_input(
615 "verification.status must be 'not_achieved' for progress review",
616 ));
617 }
618 verification.check = verification.check.trim().to_string();
619 verification.summary = verification.summary.trim().to_string();
620 if verification.check.is_empty() {
621 return Err(ToolError::invalid_input("verification.check is required"));
622 }
623 if verification.summary.is_empty() {
624 return Err(ToolError::invalid_input("verification.summary is required"));
625 }
626 Ok(verification)
627 }
628
629 fn json_result(snapshot: &GoalSnapshot) -> Result<ToolResult, ToolError> {
630 ToolResult::json(snapshot).map_err(|err| ToolError::execution_failed(err.to_string()))
631 }
632
633 fn require_root_goal_mutation(context: &ToolContext) -> Result<(), ToolError> {
634 if context.owner_agent_id.is_some() {
635 return Err(ToolError::invalid_input(
636 "Goal lifecycle mutation is root-agent only; sub-agents may inspect the parent goal with get_goal.",
637 ));
638 }
639 Ok(())
640 }
641
642 pub struct CreateGoalTool {
643 goal_state: SharedGoalState,
644 }
645
646 impl CreateGoalTool {
647 #[must_use]
648 pub fn new(goal_state: SharedGoalState) -> Self {
649 Self { goal_state }
650 }
651 }
652
653 #[async_trait]
654 impl ToolSpec for CreateGoalTool {
655 fn name(&self) -> &'static str {
656 "create_goal"
657 }
658
659 fn description(&self) -> &'static str {
660 "Create the current runtime goal. Use this only when the user explicitly asks to pursue a persistent objective and no unfinished goal exists; complete or clear an unfinished goal before creating another."
661 }
662
663 fn input_schema(&self) -> Value {
664 json!({
665 "type": "object",
666 "properties": {
667 "objective": {
668 "type": "string",
669 "description": "The full objective to pursue. Keep the complete user goal, not a shortened one-turn version."
670 },
671 "token_budget": {
672 "type": "integer",
673 "minimum": 0,
674 "description": "Optional soft token budget for the goal."
675 }
676 },
677 "required": ["objective"],
678 "additionalProperties": false
679 })
680 }
681
682 fn capabilities(&self) -> Vec<ToolCapability> {
683 Vec::new()
684 }
685
686 fn approval_requirement(&self) -> ApprovalRequirement {
687 ApprovalRequirement::Auto
688 }
689
690 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
691 require_root_goal_mutation(context)?;
692 let objective = required_str(&input, "objective")?.trim().to_string();
693 if objective.is_empty() {
694 return Err(ToolError::invalid_input("objective cannot be empty"));
695 }
696 let token_budget = parse_token_budget(&input)?;
697 let snapshot = {
698 let mut state = lock_goal_state(&self.goal_state)?;
699 state
700 .create(objective, token_budget)
701 .map_err(ToolError::invalid_input)?;
702 state.snapshot()
703 };
704 json_result(&snapshot)
705 }
706 }
707
708 pub struct GetGoalTool {
709 goal_state: SharedGoalState,
710 }
711
712 impl GetGoalTool {
713 #[must_use]
714 pub fn new(goal_state: SharedGoalState) -> Self {
715 Self { goal_state }
716 }
717 }
718
719 #[async_trait]
720 impl ToolSpec for GetGoalTool {
721 fn name(&self) -> &'static str {
722 "get_goal"
723 }
724
725 fn description(&self) -> &'static str {
726 "Inspect the current runtime goal state, including objective, status, token budget, elapsed time, evidence, and blocker."
727 }
728
729 fn input_schema(&self) -> Value {
730 json!({
731 "type": "object",
732 "properties": {},
733 "additionalProperties": false
734 })
735 }
736
737 fn capabilities(&self) -> Vec<ToolCapability> {
738 vec![ToolCapability::ReadOnly]
739 }
740
741 fn approval_requirement(&self) -> ApprovalRequirement {
742 ApprovalRequirement::Auto
743 }
744
745 fn supports_parallel(&self) -> bool {
746 true
747 }
748
749 async fn execute(
750 &self,
751 _input: Value,
752 _context: &ToolContext,
753 ) -> Result<ToolResult, ToolError> {
754 let snapshot = {
755 let state = lock_goal_state(&self.goal_state)?;
756 state.snapshot()
757 };
758 json_result(&snapshot)
759 }
760 }
761
762 pub struct UpdateGoalTool {
763 goal_state: SharedGoalState,
764 }
765
766 impl UpdateGoalTool {
767 #[must_use]
768 pub fn new(goal_state: SharedGoalState) -> Self {
769 Self { goal_state }
770 }
771 }
772
773 #[async_trait]
774 impl ToolSpec for UpdateGoalTool {
775 fn name(&self) -> &'static str {
776 "update_goal"
777 }
778
779 fn description(&self) -> &'static str {
780 "Update the runtime goal completion gate. Critical verification may seal one immutable completion contract. Advisory review is append-only context and never completes, blocks, or pauses the goal. Mark blocked when progress requires user input."
781 }
782
783 fn input_schema(&self) -> Value {
784 json!({
785 "type": "object",
786 "properties": {
787 "status": {
788 "type": "string",
789 "enum": ["complete", "blocked", "not_achieved", "advisory"],
790 "description": "Use complete only when a critical verifier proves the goal; not_achieved to record verifier gaps; blocked when meaningful progress cannot continue; advisory to append best-effort context without changing lifecycle state."
791 },
792 "evidence": {
793 "type": "string",
794 "description": "Required when status is complete. Briefly cite the proof that the goal is done."
795 },
796 "verification": {
797 "type": "object",
798 "description": "Required when status is complete or not_achieved. A verifier-as-judge receipt from a concrete check, such as Run action=\"verifiers\" or an equivalent project-specific gate.",
799 "properties": {
800 "status": {
801 "type": "string",
802 "enum": ["passed", "not_applicable", "not_achieved"],
803 "description": "Use passed when a concrete verifier/check succeeded; not_applicable when no automated verifier applies; not_achieved when the verifier found concrete remaining gaps."
804 },
805 "check": {
806 "type": "string",
807 "description": "The verifier/check that passed."
808 },
809 "summary": {
810 "type": "string",
811 "description": "Brief result summary from the verifier/check."
812 },
813 "role": {
814 "type": "string",
815 "enum": ["critical", "advisory"],
816 "description": "Critical reviews may satisfy the judged completion contract. Advisory reviews are fail-open and cannot complete it. Defaults to critical for compatibility."
817 },
818 "gaps": {
819 "type": "array",
820 "items": {"type": "string"},
821 "description": "Concrete remaining gaps. Required for critical not_achieved reviews; order and duplicate wording do not affect the stall fingerprint."
822 }
823 },
824 "required": ["status", "check", "summary"],
825 "additionalProperties": false
826 },
827 "blocker": {
828 "type": "string",
829 "description": "Required when status is blocked. Explain the condition preventing progress."
830 },
831 "advisory": {
832 "type": "string",
833 "description": "Required when status is advisory. Appended separately from the judged completion contract."
834 }
835 },
836 "required": ["status"],
837 "additionalProperties": false
838 })
839 }
840
841 fn capabilities(&self) -> Vec<ToolCapability> {
842 Vec::new()
843 }
844
845 fn approval_requirement(&self) -> ApprovalRequirement {
846 ApprovalRequirement::Auto
847 }
848
849 async fn execute(&self, input: Value, context: &ToolContext) -> Result<ToolResult, ToolError> {
850 require_root_goal_mutation(context)?;
851 // #5123-class: `objective` used to be accepted and silently ignored
852 // with a success receipt. The objective is immutable after
853 // create_goal; fail fast and name the corrective path.
854 if input
855 .get("objective")
856 .and_then(Value::as_str)
857 .is_some_and(|value| !value.trim().is_empty())
858 {
859 return Err(ToolError::invalid_input(
860 "update_goal cannot change the objective — it is immutable after create_goal. \
861 Mark the current goal complete or blocked, then create_goal with the new objective",
862 ));
863 }
864 let status = required_str(&input, "status")?.trim().to_ascii_lowercase();
865 let snapshot = {
866 let mut state = lock_goal_state(&self.goal_state)?;
867 match status.as_str() {
868 "complete" => {
869 let evidence = input
870 .get("evidence")
871 .and_then(Value::as_str)
872 .map(str::trim)
873 .unwrap_or_default()
874 .to_string();
875 if evidence.is_empty() {
876 return Err(ToolError::invalid_input(
877 "evidence is required when status is complete",
878 ));
879 }
880 let verification = parse_completion_verification(&input)?;
881 state
882 .mark_complete(evidence, verification)
883 .map_err(ToolError::invalid_input)?;
884 }
885 "blocked" => {
886 let blocker = input
887 .get("blocker")
888 .and_then(Value::as_str)
889 .map(str::trim)
890 .unwrap_or_default()
891 .to_string();
892 if blocker.is_empty() {
893 return Err(ToolError::invalid_input(
894 "blocker is required when status is blocked",
895 ));
896 }
897 state
898 .mark_blocked(blocker)
899 .map_err(ToolError::invalid_input)?;
900 }
901 "not_achieved" => {
902 let verification = parse_progress_verification(&input)?;
903 state
904 .record_not_achieved(verification)
905 .map_err(ToolError::invalid_input)?;
906 }
907 "advisory" => {
908 let advisory = input
909 .get("advisory")
910 .and_then(Value::as_str)
911 .map(str::trim)
912 .unwrap_or_default()
913 .to_string();
914 if advisory.is_empty() {
915 return Err(ToolError::invalid_input(
916 "advisory is required when status is advisory",
917 ));
918 }
919 state
920 .record_advisory(advisory)
921 .map_err(ToolError::invalid_input)?;
922 }
923 other => {
924 return Err(ToolError::invalid_input(format!(
925 "unsupported goal status '{other}'; update_goal can only mark complete or blocked, record not_achieved verifier gaps, or append advisory context"
926 )));
927 }
928 }
929 state.snapshot()
930 };
931 json_result(&snapshot)
932 }
933 }
934
935 #[cfg(test)]
936 mod tests {
937 use serde_json::{Value, json};
938
939 use super::*;
940
941 #[tokio::test]
942 async fn update_goal_rejects_objective_knob_instead_of_ignoring_it() {
943 // #5123-class: `objective` used to return a success receipt with no
944 // behavior. It is immutable after create_goal; the knob is gone from
945 // the schema and supplying it fails fast with the corrective path.
946 let state = new_shared_goal_state();
947 let ctx = ToolContext::new(".");
948 let create = CreateGoalTool::new(state.clone());
949 create
950 .execute(json!({"objective": "ship the runtime slice"}), &ctx)
951 .await
952 .expect("create goal");
953
954 let update = UpdateGoalTool::new(state.clone());
955 let schema = update.input_schema();
956 assert!(
957 schema["properties"].get("objective").is_none(),
958 "ignored objective knob must not be advertised: {schema}"
959 );
960
961 let err = update
962 .execute(
963 json!({"status": "blocked", "blocker": "x", "objective": "different goal"}),
964 &ctx,
965 )
966 .await
967 .expect_err("objective must not be silently ignored");
968 let message = format!("{err}");
969 assert!(message.contains("immutable"), "{message}");
970 assert!(message.contains("create_goal"), "{message}");
971 // The rejected call must not have mutated goal state.
972 assert!(state.lock().expect("goal lock").is_active());
973 }
974
975 #[tokio::test]
976 async fn create_get_and_complete_goal() {
977 let state = new_shared_goal_state();
978 let ctx = ToolContext::new(".");
979
980 let create = CreateGoalTool::new(state.clone());
981 let created = create
982 .execute(
983 json!({
984 "objective": "ship the runtime slice",
985 "token_budget": 1200
986 }),
987 &ctx,
988 )
989 .await
990 .expect("create goal");
991 assert!(created.success);
992 let created_json: Value = serde_json::from_str(&created.content).expect("created json");
993 assert_eq!(
994 created_json.get("status").and_then(Value::as_str),
995 Some("active")
996 );
997
998 let get = GetGoalTool::new(state.clone());
999 let current = get.execute(json!({}), &ctx).await.expect("get goal");
1000 assert!(current.content.contains("ship the runtime slice"));
1001 let current_json: Value = serde_json::from_str(&current.content).expect("current json");
1002 assert_eq!(
1003 current_json.get("token_budget").and_then(Value::as_u64),
1004 Some(1200)
1005 );
1006
1007 let update = UpdateGoalTool::new(state.clone());
1008 let completed = update
1009 .execute(
1010 json!({
1011 "status": "complete",
1012 "evidence": "focused tests passed",
1013 "verification": {
1014 "status": "passed",
1015 "check": "cargo test -p codewhale-tui goal_loop",
1016 "summary": "focused tests passed"
1017 }
1018 }),
1019 &ctx,
1020 )
1021 .await
1022 .expect("complete goal");
1023 let completed_json: Value =
1024 serde_json::from_str(&completed.content).expect("completed json");
1025 assert_eq!(
1026 completed_json.get("status").and_then(Value::as_str),
1027 Some("complete")
1028 );
1029 assert!(completed.content.contains("focused tests passed"));
1030 assert!(!state.lock().expect("goal lock").is_active());
1031 }
1032
1033 #[test]
1034 fn unfinished_goal_replacement_fails_closed_without_mutating_state() {
1035 for status in [GoalStatus::Active, GoalStatus::Paused, GoalStatus::Blocked] {
1036 let mut state = GoalState::default();
1037 state.sync_from_host_status(
1038 Some("preserve the current objective"),
1039 Some(1_200),
1040 status,
1041 );
1042 state.record_usage(300, 12);
1043 state.record_continuation();
1044 let before = state.snapshot();
1045
1046 let error = state
1047 .create("replace it silently".to_string(), Some(99))
1048 .expect_err("unfinished goal replacement must fail");
1049
1050 assert!(
1051 error.contains("unfinished goal"),
1052 "status {status:?}: {error}"
1053 );
1054 assert_eq!(
1055 state.snapshot(),
1056 before,
1057 "status {status:?} must preserve the entire goal snapshot"
1058 );
1059 }
1060 }
1061
1062 #[test]
1063 fn same_objective_goal_host_resume_clears_terminal_payloads_and_preserves_progress() {
1064 let mut blocked = GoalState::default();
1065 blocked
1066 .create("resume the release goal".to_string(), Some(4_000))
1067 .expect("create blocked fixture");
1068 blocked.record_usage(750, 44);
1069 blocked.record_continuation();
1070 blocked
1071 .mark_blocked("provider failed".to_string())
1072 .expect("block goal");
1073
1074 blocked.sync_from_host_status(
1075 Some("resume the release goal"),
1076 Some(4_000),
1077 GoalStatus::Active,
1078 );
1079
1080 let resumed = blocked.snapshot();
1081 assert_eq!(resumed.status, "active");
1082 assert_eq!(resumed.tokens_used, 750);
1083 assert_eq!(resumed.time_used_seconds, 44);
1084 assert_eq!(resumed.continuation_count, 1);
1085 assert_eq!(resumed.evidence, None);
1086 assert_eq!(resumed.blocker, None);
1087 assert_eq!(resumed.completion_verification, None);
1088 let prompt = render_continuation_prompt(&resumed, resumed.continuation_count);
1089 assert!(prompt.contains("\"blocker\": null"), "{prompt}");
1090
1091 let mut completed = GoalState::default();
1092 completed
1093 .create("resume verified work".to_string(), None)
1094 .expect("create completed fixture");
1095 completed
1096 .mark_complete(
1097 "focused tests passed".to_string(),
1098 GoalCompletionVerification {
1099 status: "passed".to_string(),
1100 check: "cargo test".to_string(),
1101 summary: "goal tests passed".to_string(),
1102 ..Default::default()
1103 },
1104 )
1105 .expect("complete goal");
1106
1107 completed.sync_from_host_status(Some("resume verified work"), None, GoalStatus::Active);
1108 let resumed = completed.snapshot();
1109 assert_eq!(resumed.status, "active");
1110 assert_eq!(resumed.evidence, None);
1111 assert_eq!(resumed.blocker, None);
1112 assert_eq!(resumed.completion_verification, None);
1113 }
1114
1115 #[test]
1116 fn completed_goal_can_be_replaced_with_fresh_accounting() {
1117 let mut state = GoalState::default();
1118 state
1119 .create("finish the first objective".to_string(), Some(1_200))
1120 .expect("create first goal");
1121 state.record_usage(300, 12);
1122 state.record_continuation();
1123 state
1124 .mark_complete(
1125 "focused tests passed".to_string(),
1126 GoalCompletionVerification {
1127 status: "passed".to_string(),
1128 check: "cargo test".to_string(),
1129 summary: "goal tests passed".to_string(),
1130 ..Default::default()
1131 },
1132 )
1133 .expect("complete first goal");
1134
1135 state
1136 .create("start the next objective".to_string(), Some(2_400))
1137 .expect("completed goal may be replaced");
1138
1139 let snapshot = state.snapshot();
1140 assert_eq!(
1141 snapshot.objective.as_deref(),
1142 Some("start the next objective")
1143 );
1144 assert_eq!(snapshot.status, "active");
1145 assert_eq!(snapshot.token_budget, Some(2_400));
1146 assert_eq!(snapshot.tokens_used, 0);
1147 assert_eq!(snapshot.time_used_seconds, 0);
1148 assert_eq!(snapshot.continuation_count, 0);
1149 assert_eq!(snapshot.evidence, None);
1150 assert_eq!(snapshot.blocker, None);
1151 assert_eq!(snapshot.completion_verification, None);
1152 }
1153
1154 #[tokio::test]
1155 async fn subagent_context_cannot_mutate_parent_goal() {
1156 let state = new_shared_goal_state_from_host_status(
1157 Some("keep root lifecycle authority".to_string()),
1158 Some(1_200),
1159 GoalStatus::Active,
1160 );
1161 let before = state.lock().expect("goal lock").snapshot();
1162 let child_context = ToolContext::new(".").with_owner_agent("agent_child", "child verifier");
1163
1164 let create_error = CreateGoalTool::new(state.clone())
1165 .execute(
1166 json!({"objective": "replace the parent goal"}),
1167 &child_context,
1168 )
1169 .await
1170 .expect_err("child create_goal must fail");
1171 assert!(create_error.to_string().contains("root-agent only"));
1172
1173 let update_error = UpdateGoalTool::new(state.clone())
1174 .execute(
1175 json!({"status": "blocked", "blocker": "child decided to stop"}),
1176 &child_context,
1177 )
1178 .await
1179 .expect_err("child update_goal must fail");
1180 assert!(update_error.to_string().contains("root-agent only"));
1181
1182 assert_eq!(
1183 state.lock().expect("goal lock").snapshot(),
1184 before,
1185 "rejected child mutations must leave the parent goal unchanged"
1186 );
1187 }
1188
1189 #[tokio::test]
1190 async fn update_goal_requires_completion_evidence() {
1191 let state = new_shared_goal_state_from_host_status(
1192 Some("prove completion".to_string()),
1193 None,
1194 GoalStatus::Active,
1195 );
1196 let update = UpdateGoalTool::new(state);
1197 let err = update
1198 .execute(json!({"status": "complete"}), &ToolContext::new("."))
1199 .await
1200 .expect_err("missing evidence should fail");
1201
1202 assert!(err.to_string().contains("evidence is required"));
1203 }
1204
1205 #[tokio::test]
1206 async fn update_goal_accepts_not_applicable_verification_for_non_verifiable_goals() {
1207 let state = new_shared_goal_state_from_host_status(
1208 Some("write the release notes".to_string()),
1209 None,
1210 GoalStatus::Active,
1211 );
1212 let update = UpdateGoalTool::new(state.clone());
1213 let completed = update
1214 .execute(
1215 json!({
1216 "status": "complete",
1217 "evidence": "release notes drafted and reviewed in thread",
1218 "verification": {
1219 "status": "not_applicable",
1220 "check": "no automated verifier applies",
1221 "summary": "writing task completed with evidence in thread"
1222 }
1223 }),
1224 &ToolContext::new("."),
1225 )
1226 .await
1227 .expect("non-verifiable goal should complete");
1228
1229 let completed_json: Value =
1230 serde_json::from_str(&completed.content).expect("completed json");
1231 assert_eq!(
1232 completed_json.get("status").and_then(Value::as_str),
1233 Some("complete")
1234 );
1235 assert_eq!(
1236 completed_json
1237 .get("completion_verification")
1238 .and_then(|verification| verification.get("status"))
1239 .and_then(Value::as_str),
1240 Some("not_applicable")
1241 );
1242 assert!(!state.lock().expect("goal lock").is_active());
1243 }
1244
1245 #[tokio::test]
1246 async fn update_goal_requires_passed_verification_to_complete() {
1247 let state = new_shared_goal_state_from_host_status(
1248 Some("prove completion".to_string()),
1249 None,
1250 GoalStatus::Active,
1251 );
1252 let update = UpdateGoalTool::new(state.clone());
1253 let err = update
1254 .execute(
1255 json!({
1256 "status": "complete",
1257 "evidence": "all checks look good"
1258 }),
1259 &ToolContext::new("."),
1260 )
1261 .await
1262 .expect_err("missing verifier gate should fail");
1263
1264 assert!(err.to_string().contains("verification is required"));
1265 assert!(state.lock().expect("goal lock").is_active());
1266 }
1267
1268 #[tokio::test]
1269 async fn advisory_review_is_append_only_and_fail_open() {
1270 let state = new_shared_goal_state_from_host_status(
1271 Some("keep the judged contract authoritative".to_string()),
1272 None,
1273 GoalStatus::Active,
1274 );
1275 let update = UpdateGoalTool::new(state.clone());
1276 update
1277 .execute(
1278 json!({
1279 "status": "advisory",
1280 "advisory": "Consider a narrower compatibility test."
1281 }),
1282 &ToolContext::new("."),
1283 )
1284 .await
1285 .expect("advisory note");
1286 let result = state.lock().expect("goal lock").snapshot();
1287
1288 assert_eq!(result.status, "active");
1289 assert_eq!(result.advisories.len(), 1);
1290 assert_eq!(
1291 result.advisories[0].summary,
1292 "Consider a narrower compatibility test."
1293 );
1294 assert!(result.completion_verification.is_none());
1295 }
1296
1297 #[tokio::test]
1298 async fn advisory_verification_cannot_complete_goal() {
1299 let state = new_shared_goal_state_from_host_status(
1300 Some("require a critical judge".to_string()),
1301 None,
1302 GoalStatus::Active,
1303 );
1304 let err = UpdateGoalTool::new(state.clone())
1305 .execute(
1306 json!({
1307 "status": "complete",
1308 "evidence": "an advisor liked it",
1309 "verification": {
1310 "status": "passed",
1311 "check": "advisory review",
1312 "summary": "looks reasonable",
1313 "role": "advisory"
1314 }
1315 }),
1316 &ToolContext::new("."),
1317 )
1318 .await
1319 .expect_err("advisory completion must fail closed");
1320
1321 assert!(err.to_string().contains("advisory review cannot complete"));
1322 assert!(state.lock().expect("goal lock").is_active());
1323 }
1324
1325 #[test]
1326 fn judged_completion_contract_is_fingerprinted_and_immutable() {
1327 let mut state = GoalState::default();
1328 state
1329 .create("seal the release candidate".to_string(), None)
1330 .expect("create goal");
1331 state
1332 .mark_complete(
1333 "locked tests passed".to_string(),
1334 GoalCompletionVerification {
1335 status: "passed".to_string(),
1336 check: "cargo test --locked".to_string(),
1337 summary: "all required tests passed".to_string(),
1338 ..Default::default()
1339 },
1340 )
1341 .expect("seal judged contract");
1342 let sealed = state.snapshot();
1343 let fingerprint = &sealed
1344 .completion_verification
1345 .as_ref()
1346 .expect("completion contract")
1347 .contract_fingerprint;
1348 assert_eq!(fingerprint.len(), 64);
1349
1350 let err = state
1351 .mark_complete(
1352 "replace the evidence".to_string(),
1353 GoalCompletionVerification {
1354 status: "passed".to_string(),
1355 check: "different check".to_string(),
1356 summary: "different result".to_string(),
1357 ..Default::default()
1358 },
1359 )
1360 .expect_err("sealed contract must be immutable");
1361 assert!(err.contains("already sealed"));
1362 assert_eq!(state.snapshot(), sealed);
1363 }
1364
1365 fn not_achieved_review(role: GoalReviewRole, gaps: &[&str]) -> GoalProgressVerification {
1366 GoalProgressVerification {
1367 status: "not_achieved".to_string(),
1368 check: "critical verifier".to_string(),
1369 summary: "remaining work found".to_string(),
1370 role,
1371 gaps: gaps.iter().map(|gap| (*gap).to_string()).collect(),
1372 }
1373 }
1374
1375 #[test]
1376 fn equivalent_gap_sets_have_one_stable_fingerprint() {
1377 let first = gap_fingerprint(&[
1378 " Add a regression test ".to_string(),
1379 "Fix provider copy".to_string(),
1380 ]);
1381 let reordered = gap_fingerprint(&[
1382 "fix PROVIDER copy".to_string(),
1383 "add a regression test".to_string(),
1384 "Add a regression test".to_string(),
1385 ]);
1386 assert_eq!(first, reordered);
1387 assert_eq!(first.expect("fingerprint").len(), 64);
1388 }
1389
1390 #[test]
1391 fn three_identical_critical_gap_sets_pause_for_no_progress() {
1392 let mut state = GoalState::default();
1393 state
1394 .create("finish the release candidate".to_string(), None)
1395 .expect("create goal");
1396
1397 for expected_count in 1..=NO_PROGRESS_STALL_THRESHOLD {
1398 state
1399 .record_not_achieved(not_achieved_review(
1400 GoalReviewRole::Critical,
1401 &[
1402 "add the missing compatibility test",
1403 "fix the final warning",
1404 ],
1405 ))
1406 .expect("record verifier gaps");
1407 assert_eq!(state.snapshot().repeated_gap_count, expected_count);
1408 }
1409
1410 let stalled = state.snapshot();
1411 assert_eq!(stalled.status, "paused");
1412 assert_eq!(stalled.pause_reason, Some(GoalPauseReason::NoProgress));
1413 assert!(stalled.last_gap_fingerprint.is_some());
1414 }
1415
1416 #[test]
1417 fn changed_gaps_reset_stall_counter_and_advice_never_advances_it() {
1418 let mut state = GoalState::default();
1419 state
1420 .create("keep making measurable progress".to_string(), None)
1421 .expect("create goal");
1422 state
1423 .record_not_achieved(not_achieved_review(
1424 GoalReviewRole::Critical,
1425 &["first gap"],
1426 ))
1427 .expect("first critical review");
1428 state
1429 .record_not_achieved(not_achieved_review(
1430 GoalReviewRole::Critical,
1431 &["first gap"],
1432 ))
1433 .expect("repeat critical review");
1434 assert_eq!(state.snapshot().repeated_gap_count, 2);
1435
1436 state
1437 .record_not_achieved(not_achieved_review(
1438 GoalReviewRole::Advisory,
1439 &["advisor-only concern"],
1440 ))
1441 .expect("advisory review is fail-open");
1442 let after_advice = state.snapshot();
1443 assert_eq!(after_advice.repeated_gap_count, 2);
1444 assert_eq!(after_advice.advisories.len(), 1);
1445 assert_eq!(after_advice.status, "active");
1446
1447 state
1448 .record_not_achieved(not_achieved_review(
1449 GoalReviewRole::Critical,
1450 &["a different remaining gap"],
1451 ))
1452 .expect("changed critical review");
1453 let progressed = state.snapshot();
1454 assert_eq!(progressed.repeated_gap_count, 1);
1455 assert_eq!(progressed.status, "active");
1456 }
1457
1458 #[tokio::test]
1459 async fn update_goal_not_achieved_receipts_pause_after_threshold() {
1460 let state = new_shared_goal_state_from_host_status(
1461 Some("close every verifier gap".to_string()),
1462 None,
1463 GoalStatus::Active,
1464 );
1465 let update = UpdateGoalTool::new(state.clone());
1466 for _ in 0..NO_PROGRESS_STALL_THRESHOLD {
1467 update
1468 .execute(
1469 json!({
1470 "status": "not_achieved",
1471 "verification": {
1472 "status": "not_achieved",
1473 "check": "cargo test",
1474 "summary": "the same regression remains",
1475 "role": "critical",
1476 "gaps": ["fix the failing regression"]
1477 }
1478 }),
1479 &ToolContext::new("."),
1480 )
1481 .await
1482 .expect("record not-achieved receipt");
1483 }
1484
1485 let snapshot = state.lock().expect("goal lock").snapshot();
1486 assert_eq!(snapshot.status, "paused");
1487 assert_eq!(snapshot.pause_reason, Some(GoalPauseReason::NoProgress));
1488 }
1489
1490 #[tokio::test]
1491 async fn update_goal_rejects_model_resume() {
1492 let state = new_shared_goal_state_from_host_status(
1493 Some("pause remains host controlled".to_string()),
1494 None,
1495 GoalStatus::Paused,
1496 );
1497 let update = UpdateGoalTool::new(state);
1498 let err = update
1499 .execute(json!({"status": "active"}), &ToolContext::new("."))
1500 .await
1501 .expect_err("model resume should fail");
1502
1503 assert!(err.to_string().contains("complete or blocked"));
1504 }
1505
1506 #[test]
1507 fn paused_host_goal_is_not_active() {
1508 let state = new_shared_goal_state_from_host_status(
1509 Some("wait for user".to_string()),
1510 Some(42),
1511 GoalStatus::Paused,
1512 );
1513 let snapshot = state.lock().expect("goal lock").snapshot();
1514
1515 assert_eq!(snapshot.status, "paused");
1516 assert_eq!(snapshot.token_budget, Some(42));
1517 assert_eq!(snapshot.pause_reason, Some(GoalPauseReason::User));
1518 assert!(!snapshot.is_active());
1519 }
1520
1521 #[test]
1522 fn goal_state_projects_usage_and_continuations() {
1523 let state = new_shared_goal_state_from_host_status(
1524 Some("persist accounting".to_string()),
1525 Some(1_000),
1526 GoalStatus::Active,
1527 );
1528 {
1529 let mut goal = state.lock().expect("goal lock");
1530 goal.record_usage(300, 12);
1531 goal.record_continuation();
1532 }
1533
1534 let snapshot = state.lock().expect("goal lock").snapshot();
1535 assert_eq!(snapshot.tokens_used, 300);
1536 assert_eq!(snapshot.time_used_seconds, 12);
1537 assert_eq!(snapshot.continuation_count, 1);
1538 }
1539
1540 #[test]
1541 fn completed_goal_snapshot_freezes_elapsed() {
1542 // Regression: a completed goal's snapshot elapsed_seconds must not keep
1543 // growing. Before the fix, snapshot() always used started_at.elapsed(),
1544 // so a finished goal's elapsed kept ticking in the sidebar/tool output.
1545 let state = new_shared_goal_state_from_host_status(
1546 Some("freeze on completion".to_string()),
1547 None,
1548 GoalStatus::Active,
1549 );
1550 let first = {
1551 let mut goal = state.lock().expect("goal lock");
1552 goal.mark_complete(
1553 "evidence".to_string(),
1554 GoalCompletionVerification {
1555 status: "passed".to_string(),
1556 check: "cargo test".to_string(),
1557 summary: "ok".to_string(),
1558 ..Default::default()
1559 },
1560 )
1561 .expect("mark complete");
1562 goal.snapshot()
1563 };
1564 let elapsed_at_completion = first.elapsed_seconds.expect("elapsed present");
1565
1566 // Sleep past a whole-second boundary. Under the old (buggy) code,
1567 // snapshot() returned started_at.elapsed().as_secs(), so this would
1568 // tick up by at least one second and the assertion below would fail.
1569 // With the freeze, the completed snapshot stays at the captured value.
1570 std::thread::sleep(std::time::Duration::from_millis(1_100));
1571 let second = state.lock().expect("goal lock").snapshot();
1572 assert_eq!(second.status, "complete");
1573 assert_eq!(
1574 second.elapsed_seconds,
1575 Some(elapsed_at_completion),
1576 "completed goal elapsed must be frozen, not keep ticking"
1577 );
1578 }
1579
1580 #[test]
1581 fn protocol_thread_goal_converts_to_runtime_snapshot() {
1582 let snapshot = GoalSnapshot::from_thread_goal(&codewhale_protocol::ThreadGoal {
1583 thread_id: "thread-1".to_string(),
1584 goal_id: "goal-1".to_string(),
1585 objective: "Bridge the goal models".to_string(),
1586 status: codewhale_protocol::ThreadGoalStatus::Active,
1587 token_budget: Some(2_000),
1588 tokens_used: 750,
1589 time_used_seconds: 44,
1590 continuation_count: 3,
1591 created_at: 1,
1592 updated_at: 2,
1593 });
1594
1595 assert_eq!(
1596 snapshot.objective.as_deref(),
1597 Some("Bridge the goal models")
1598 );
1599 assert_eq!(snapshot.status, "active");
1600 assert_eq!(snapshot.token_budget, Some(2_000));
1601 assert_eq!(snapshot.tokens_used, 750);
1602 assert_eq!(snapshot.time_used_seconds, 44);
1603 assert_eq!(snapshot.continuation_count, 3);
1604 }
1605
1606 #[test]
1607 fn protocol_limit_statuses_keep_distinct_pause_reasons() {
1608 for (status, reason) in [
1609 (
1610 codewhale_protocol::ThreadGoalStatus::UsageLimited,
1611 GoalPauseReason::UsageLimit,
1612 ),
1613 (
1614 codewhale_protocol::ThreadGoalStatus::BudgetLimited,
1615 GoalPauseReason::BudgetLimit,
1616 ),
1617 ] {
1618 let (projected, projected_reason) = thread_goal_status_projection(status);
1619 assert_eq!(projected, GoalStatus::Paused);
1620 assert_eq!(projected_reason, Some(reason));
1621 }
1622 }
1623
1624 #[test]
1625 fn continuation_prompt_includes_bound_and_goal_state() {
1626 let snapshot = GoalSnapshot {
1627 objective: Some("finish issue 2199".to_string()),
1628 status: "active".to_string(),
1629 token_budget: None,
1630 tokens_used: 0,
1631 time_used_seconds: 0,
1632 continuation_count: 0,
1633 elapsed_seconds: Some(5),
1634 evidence: None,
1635 blocker: None,
1636 pause_reason: None,
1637 completion_verification: None,
1638 ..Default::default()
1639 };
1640
1641 let prompt = render_continuation_prompt(&snapshot, 2);
1642 assert!(prompt.contains("Goal Continuation"));
1643 assert!(prompt.contains("finish issue 2199"));
1644 assert!(prompt.contains("Continuation pass #2"));
1645 assert!(prompt.contains("waiting for user response"));
1646 }
1647
1648 #[test]
1649 fn update_goal_contract_treats_required_user_input_as_blocking() {
1650 let update = UpdateGoalTool::new(new_shared_goal_state());
1651 assert!(update.description().contains("requires user input"));
1652 }
1653 }
1654
1654 lines RUST