- 1
use serde::{Deserialize, Serialize}; - 2
- 3
use crate::outcome::Command; - 4
- 5
/// How a new human message relates to the work already in progress. - 6
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 7
#[serde(rename_all = "snake_case")] - 8
pub enum GoalRelation { - 9
New, - 10
AddsTo, - 11
Corrects, - 12
Replaces, - 13
Status, - 14
Pauses, - 15
Resumes, - 16
Cancels, - 17
} - 18
- 19
/// A durable, append-only interpretation of a change to collaborative work. - 20
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 21
pub struct GoalUpdate { - 22
pub revision: u64, - 23
pub relation: GoalRelation, - 24
pub request: String, - 25
#[serde(default, skip_serializing_if = "Option::is_none")] - 26
pub supersedes_revision: Option<u64>, - 27
/// Whether a person stated this as the goal (`/goal fix`, `/goal - 28
/// replace`) rather than the runtime taking a message as one. - 29
#[serde(default, skip_serializing_if = "std::ops::Not::not")] - 30
pub explicit: bool, - 31
} - 32
- 33
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] - 34
#[serde(rename_all = "snake_case")] - 35
pub enum GoalControlState { - 36
Active, - 37
Paused, - 38
Cancelled, - 39
} - 40
- 41
/// Deterministic current-state projection over append-only goal updates. - 42
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)] - 43
pub struct GoalState { - 44
pub revision: u64, - 45
pub objective: String, - 46
pub additions: Vec<String>, - 47
pub superseded_revisions: Vec<u64>, - 48
pub control: GoalControlState, - 49
/// Whether a person stated the objective as the goal. Otherwise it is - 50
/// only the conversation's first message, and it is never presented to - 51
/// the model as the primary objective — "hi" is not one. - 52
#[serde(default)] - 53
pub explicit: bool, - 54
} - 55
- 56
impl GoalState { - 57
pub fn from_updates(updates: impl IntoIterator<Item = GoalUpdate>) -> Option<Self> { - 58
let mut state = None; - 59
for update in updates { - 60
let current = state.get_or_insert_with(|| Self { - 61
revision: 0, - 62
objective: String::new(), - 63
additions: Vec::new(), - 64
superseded_revisions: Vec::new(), - 65
control: GoalControlState::Active, - 66
explicit: false, - 67
}); - 68
current.revision = update.revision; - 69
match update.relation { - 70
GoalRelation::New | GoalRelation::Replaces => { - 71
current.objective = update.request; - 72
current.additions.clear(); - 73
current.control = GoalControlState::Active; - 74
current.explicit = update.explicit; - 75
} - 76
// A correction amends the objective and keeps what was added - 77
// to it; only a replacement discards the additions. - 78
GoalRelation::Corrects => { - 79
current.objective = update.request; - 80
current.control = GoalControlState::Active; - 81
current.explicit = update.explicit; - 82
} - 83
// Adding to a paused goal is working on it again: the turn - 84
// that carries the addition runs. - 85
GoalRelation::AddsTo => { - 86
current.additions.push(update.request); - 87
current.control = GoalControlState::Active; - 88
} - 89
GoalRelation::Pauses => current.control = GoalControlState::Paused, - 90
GoalRelation::Resumes => current.control = GoalControlState::Active, - 91
GoalRelation::Cancels => current.control = GoalControlState::Cancelled, - 92
GoalRelation::Status => {} - 93
} - 94
if let Some(revision) = update.supersedes_revision - 95
&& !current.superseded_revisions.contains(&revision) - 96
{ - 97
current.superseded_revisions.push(revision); - 98
} - 99
} - 100
state - 101
} - 102
- 103
/// Whether there is a goal to add to or amend: one that has not been - 104
/// called off. - 105
pub fn is_live(&self) -> bool { - 106
self.control != GoalControlState::Cancelled - 107
} - 108
} - 109
- 110
/// The relationship a request has to the goal in progress. - 111
/// - 112
/// Only an explicit [`Command`] can correct or replace the goal, or change - 113
/// its control state; every other request adds to it, or opens one when - 114
/// there is none — including when the last one was called off, because a - 115
/// message after `/stop` starts over rather than adding to cancelled work. - 116
/// There is no text classifier here on purpose: the earlier one read - 117
/// "replace the deprecated API call" as a replacement of the goal and - 118
/// cleared everything the user had added to it. - 119
pub fn goal_relation(command: Option<&Command>, goal: Option<&GoalState>) -> GoalRelation { - 120
let live = goal.is_some_and(GoalState::is_live); - 121
let if_live = |relation| if live { relation } else { GoalRelation::New }; - 122
match command { - 123
Some(Command::Status) => GoalRelation::Status, - 124
Some(Command::Pause) => GoalRelation::Pauses, - 125
Some(Command::Resume) => GoalRelation::Resumes, - 126
Some(Command::Cancel) => GoalRelation::Cancels, - 127
Some(Command::GoalFix { .. }) => if_live(GoalRelation::Corrects), - 128
Some(Command::GoalReplace { .. }) => if_live(GoalRelation::Replaces), - 129
Some(Command::Replan { .. }) - 130
| Some(Command::AddRequirement { .. }) - 131
| Some(Command::RemoveRequirement { .. }) - 132
| Some(Command::Reprioritize { .. }) - 133
| Some(Command::Approve { .. }) - 134
| Some(Command::Reject { .. }) - 135
| None => if_live(GoalRelation::AddsTo), - 136
} - 137
} - 138
- 139
/// The next goal update for a message, given the goal so far and the last - 140
/// update's revision. The one place that decides it — for the message that - 141
/// starts a turn and for one steered into a running turn — so both number - 142
/// revisions the same way and read commands the same way. - 143
pub fn next_goal_update( - 144
text: &str, - 145
goal: Option<&GoalState>, - 146
latest_revision: Option<u64>, - 147
) -> GoalUpdate { - 148
let command = crate::outcome::parse_command(text); - 149
let relation = goal_relation(command.as_ref(), goal); - 150
let explicit = matches!( - 151
command, - 152
Some(Command::GoalFix { .. } | Command::GoalReplace { .. }) - 153
); - 154
GoalUpdate { - 155
revision: latest_revision.unwrap_or(0).saturating_add(1), - 156
relation, - 157
request: command - 158
.as_ref() - 159
.and_then(Command::text) - 160
.map(str::to_string) - 161
.unwrap_or_else(|| text.to_string()), - 162
supersedes_revision: matches!(relation, GoalRelation::Corrects | GoalRelation::Replaces) - 163
.then(|| goal.map(|goal| goal.revision)) - 164
.flatten(), - 165
explicit, - 166
} - 167
} - 168
- 169
#[cfg(test)] - 170
#[allow(clippy::unwrap_used, clippy::expect_used, clippy::panic)] - 171
mod tests { - 172
use super::*; - 173
- 174
fn update(relation: GoalRelation, request: &str, revision: u64) -> GoalUpdate { - 175
GoalUpdate { - 176
revision, - 177
relation, - 178
request: request.into(), - 179
supersedes_revision: None, - 180
explicit: false, - 181
} - 182
} - 183
- 184
fn state(updates: &[GoalUpdate]) -> GoalState { - 185
GoalState::from_updates(updates.iter().cloned()).unwrap() - 186
} - 187
- 188
#[test] - 189
fn relation_follows_the_explicit_command_only() { - 190
let active = state(&[update(GoalRelation::New, "prepare a briefing", 1)]); - 191
assert_eq!( - 192
goal_relation(Some(&Command::Status), Some(&active)), - 193
GoalRelation::Status - 194
); - 195
assert_eq!(goal_relation(None, Some(&active)), GoalRelation::AddsTo); - 196
assert_eq!(goal_relation(None, None), GoalRelation::New); - 197
assert_eq!( - 198
goal_relation( - 199
Some(&Command::GoalFix { - 200
text: "use the web version".into() - 201
}), - 202
Some(&active) - 203
), - 204
GoalRelation::Corrects - 205
); - 206
assert_eq!( - 207
goal_relation( - 208
Some(&Command::GoalReplace { - 209
text: "just add the index".into() - 210
}), - 211
None - 212
), - 213
GoalRelation::New - 214
); - 215
// Text that used to be read as a replacement is an addition now. - 216
assert_eq!( - 217
goal_relation( - 218
crate::outcome::parse_command("replace the deprecated API call").as_ref(), - 219
Some(&active) - 220
), - 221
GoalRelation::AddsTo - 222
); - 223
} - 224
- 225
/// A message after `/stop` starts over; it is not an addition to work - 226
/// that was called off. - 227
#[test] - 228
fn a_cancelled_goal_is_not_added_to() { - 229
let cancelled = state(&[ - 230
update(GoalRelation::New, "prepare a briefing", 1), - 231
update(GoalRelation::Cancels, "stop", 2), - 232
]); - 233
assert_eq!(goal_relation(None, Some(&cancelled)), GoalRelation::New); - 234
let next = next_goal_update("book a table for two", Some(&cancelled), Some(2)); - 235
assert_eq!(next.relation, GoalRelation::New); - 236
assert_eq!(next.revision, 3); - 237
let after = state(&[ - 238
update(GoalRelation::New, "prepare a briefing", 1), - 239
update(GoalRelation::Cancels, "stop", 2), - 240
next, - 241
]); - 242
assert_eq!(after.objective, "book a table for two"); - 243
assert_eq!(after.control, GoalControlState::Active); - 244
} - 245
- 246
/// Adding to a paused goal works on it again. - 247
#[test] - 248
fn an_addition_resumes_a_paused_goal() { - 249
let resumed = state(&[ - 250
update(GoalRelation::New, "prepare a briefing", 1), - 251
update(GoalRelation::Pauses, "pause", 2), - 252
update(GoalRelation::AddsTo, "include sources", 3), - 253
]); - 254
assert_eq!(resumed.control, GoalControlState::Active); - 255
} - 256
- 257
/// Only a goal a person stated is one; the first message of a - 258
/// conversation is not its objective by default. - 259
#[test] - 260
fn only_a_stated_goal_is_explicit() { - 261
let inferred = next_goal_update("hi", None, None); - 262
assert!(!inferred.explicit); - 263
assert!(!state(&[inferred]).explicit); - 264
let stated = next_goal_update("/goal replace ship the release notes", None, None); - 265
assert!(stated.explicit); - 266
assert_eq!(stated.request, "ship the release notes"); - 267
assert!(state(&[stated]).explicit); - 268
} - 269
- 270
/// Revisions count every update, so two call sites can never reuse one. - 271
#[test] - 272
fn revisions_follow_the_latest_update_of_any_kind() { - 273
let paused = state(&[ - 274
update(GoalRelation::New, "prepare a briefing", 1), - 275
update(GoalRelation::Pauses, "pause", 2), - 276
]); - 277
let next = next_goal_update("also include sources", Some(&paused), Some(2)); - 278
assert_eq!(next.revision, 3); - 279
assert_eq!(next.relation, GoalRelation::AddsTo); - 280
} - 281
- 282
#[test] - 283
fn a_correction_keeps_additions_and_a_replacement_drops_them() { - 284
let base = |relation, request: &str, revision| update(relation, request, revision); - 285
let corrected = GoalState::from_updates([ - 286
base(GoalRelation::New, "prepare a briefing", 1), - 287
base(GoalRelation::AddsTo, "include sources", 2), - 288
base(GoalRelation::Corrects, "prepare a two-page briefing", 3), - 289
]) - 290
.unwrap(); - 291
assert_eq!(corrected.objective, "prepare a two-page briefing"); - 292
assert_eq!(corrected.additions, vec!["include sources"]); - 293
let replaced = GoalState::from_updates([ - 294
base(GoalRelation::New, "prepare a briefing", 1), - 295
base(GoalRelation::AddsTo, "include sources", 2), - 296
base(GoalRelation::Replaces, "just send the summary", 3), - 297
]) - 298
.unwrap(); - 299
assert!(replaced.additions.is_empty()); - 300
} - 301
- 302
#[test] - 303
fn projects_objective_additions_and_controls() { - 304
let state = GoalState::from_updates([ - 305
update(GoalRelation::New, "prepare a briefing", 1), - 306
update(GoalRelation::AddsTo, "include sources", 2), - 307
update(GoalRelation::Pauses, "pause", 3), - 308
]); - 309
assert!(state.is_some()); - 310
if let Some(state) = state { - 311
assert_eq!(state.objective, "prepare a briefing"); - 312
assert_eq!(state.additions, vec!["include sources"]); - 313
assert_eq!(state.control, GoalControlState::Paused); - 314
} - 315
} - 316
} - 317
Indexing the workspace…
Vakyartha documentation is discovering safe artifacts, anchors, and source references.