1use bamboo_agent_core::{Message, Role};
4use bamboo_skills::runtime_metadata::*;
5use bamboo_skills::{
6 ActiveWorkflow, DurableWorkflowActivation, SkillActivationDescriptor, SkillActivationSnapshot,
7 WorkflowActivationStatus, WorkflowCatalogEntry, WorkflowKind, WorkflowSelection,
8 WorkflowStatus, ACTIVE_WORKFLOW_METADATA_KEY, ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY,
9 MAX_DURABLE_WORKFLOW_ACTIVATION_BYTES, WORKFLOW_ACTIVATION_EVENT_METADATA_KEY,
10 WORKFLOW_CONTEXT_CACHE_METADATA_KEY, WORKFLOW_LAST_DYNAMIC_CONTEXT_METADATA_KEY,
11 WORKFLOW_SELECTION_METADATA_KEY,
12};
13use serde::de::DeserializeOwned;
14use serde_json::Value;
15use sha2::{Digest, Sha256};
16use std::collections::{BTreeSet, HashMap};
17
18const OBSOLETE_INSTRUCTION_KEYS: &[&str] = &[
19 ACTIVE_WORKFLOW_METADATA_KEY,
20 ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY,
21 WORKFLOW_SELECTION_METADATA_KEY,
22 SKILL_RUNTIME_ACTIVATION_GENERATION_KEY,
23 SKILL_RUNTIME_SELECTED_SKILL_REVISIONS_KEY,
24 SKILL_RUNTIME_PINNED_SNAPSHOT_KEY,
25 SKILL_RUNTIME_SELECTED_CATALOG_KEY,
26 SKILL_RUNTIME_SELECTED_SKILL_IDS_KEY,
27 SKILL_RUNTIME_SELECTED_SKILL_MODE_KEY,
28 SKILL_RUNTIME_SELECTION_SOURCE_KEY,
29 SKILL_RUNTIME_SELECTION_TRACE_KEY,
30 SKILL_RUNTIME_SELECTION_COUNT_KEY,
31 SKILL_RUNTIME_ACTIVATION_ERROR_KEY,
32 LOADED_SKILL_IDS_METADATA_KEY,
33 LAST_LOADED_SKILL_ID_METADATA_KEY,
34 LAST_LOADED_SKILL_SUMMARY_METADATA_KEY,
35 LAST_RESOURCE_READ_SUMMARY_METADATA_KEY,
36 WORKFLOW_ACTIVATION_EVENT_METADATA_KEY,
37 WORKFLOW_LAST_DYNAMIC_CONTEXT_METADATA_KEY,
38 WORKFLOW_CONTEXT_CACHE_METADATA_KEY,
39];
40
41#[derive(Debug, Default)]
42pub struct LegacySkillHistoryPlan {
43 pub insert_after: Option<usize>,
45 pub message: Option<Message>,
46 pub remove_metadata: Vec<String>,
47 pub unsupported: Option<String>,
49}
50
51fn unsupported(reason: &str) -> LegacySkillHistoryPlan {
52 LegacySkillHistoryPlan {
53 unsupported: Some(reason.into()),
54 ..Default::default()
55 }
56}
57
58fn decode<T: DeserializeOwned>(
59 metadata: &HashMap<String, String>,
60 key: &str,
61) -> Result<Option<T>, String> {
62 metadata
63 .get(key)
64 .map(|raw| {
65 if raw.len() > MAX_DURABLE_WORKFLOW_ACTIVATION_BYTES {
66 return Err(format!(
67 "legacy Instruction metadata exceeds 512 KiB: {key}"
68 ));
69 }
70 serde_json::from_str(raw).map_err(|error| format!("invalid {key}: {error}"))
71 })
72 .transpose()
73}
74
75pub fn plan_legacy_skill_history(
79 messages: &[Message],
80 metadata: &HashMap<String, String>,
81) -> Result<LegacySkillHistoryPlan, String> {
82 let Some(active) = decode::<ActiveWorkflow>(metadata, ACTIVE_WORKFLOW_METADATA_KEY)? else {
83 return Ok(unsupported("no typed active workflow"));
84 };
85 if active.kind != WorkflowKind::Instruction {
86 return Ok(unsupported(
87 "Orchestration is outside Instruction conversion",
88 ));
89 }
90 if OBSOLETE_INSTRUCTION_KEYS.iter().any(|key| {
91 metadata
92 .get(*key)
93 .is_some_and(|raw| raw.len() > MAX_DURABLE_WORKFLOW_ACTIVATION_BYTES)
94 }) {
95 return Err("populated legacy Instruction metadata exceeds 512 KiB".into());
96 }
97 if active.status != WorkflowActivationStatus::Active {
98 return Ok(unsupported("workflow is not successfully active"));
99 }
100 let Some(durable) =
101 decode::<DurableWorkflowActivation>(metadata, ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY)?
102 else {
103 return Ok(unsupported("no durable loaded snapshot"));
104 };
105 if durable.active != active
106 || durable.snapshot.skills.len() != 1
107 || durable.snapshot.catalog_revision == 0
108 {
109 return Err("active and durable Instruction evidence disagree".into());
110 }
111 let entry = durable
112 .snapshot
113 .skills
114 .get(&active.id)
115 .ok_or("active Instruction is absent from durable snapshot")?;
116 if active.id.is_empty()
117 || active.revision == 0
118 || entry.definition.id != active.id
119 || entry.catalog_entry.id != active.id
120 || entry.revision != active.revision
121 || entry.catalog_entry.revision != active.revision
122 || entry.catalog_entry.source != active.source
123 || entry.catalog_entry.kind != WorkflowKind::Instruction
124 || !entry.catalog_entry.winner
125 || entry.catalog_entry.status != WorkflowStatus::Valid
126 {
127 return Err("durable Instruction identity is inconsistent".into());
128 }
129 bamboo_domain::validate_schema(&entry.catalog_entry.argument_schema, &active.args)?;
130 let Some(pinned) =
131 decode::<SkillActivationSnapshot>(metadata, SKILL_RUNTIME_PINNED_SNAPSHOT_KEY)?
132 else {
133 return Ok(unsupported("no original pinned snapshot"));
134 };
135 let Some(catalog) =
136 decode::<Vec<WorkflowCatalogEntry>>(metadata, SKILL_RUNTIME_SELECTED_CATALOG_KEY)?
137 else {
138 return Ok(unsupported("no original selected catalog"));
139 };
140 if pinned.skills.is_empty()
141 || pinned.skills.len() > 32
142 || pinned.skills.len() != catalog.len()
143 || pinned.catalog_revision != durable.snapshot.catalog_revision
144 || pinned.selected_skill_mode != durable.snapshot.selected_skill_mode
145 {
146 return Err("original catalog/snapshot generation or mode disagree".into());
147 }
148 if catalog
149 .iter()
150 .any(|entry| entry.kind != WorkflowKind::Instruction)
151 {
152 return Ok(unsupported("mixed Orchestration pin remains untouched"));
153 }
154 let mut catalog_ids = BTreeSet::new();
155 for candidate in &catalog {
156 let original = pinned
157 .skills
158 .get(&candidate.id)
159 .ok_or("catalog candidate is not pinned")?;
160 if !catalog_ids.insert(&candidate.id)
161 || candidate != &original.catalog_entry
162 || original.definition.id != candidate.id
163 || original.revision != candidate.revision
164 || original.revision == 0
165 || !candidate.winner
166 || candidate.status != WorkflowStatus::Valid
167 {
168 return Err("original selected catalog and snapshot disagree".into());
169 }
170 }
171 let original = pinned
172 .skills
173 .get(&active.id)
174 .ok_or("active Instruction was not originally pinned")?;
175 if original.definition != entry.definition
176 || original.catalog_entry != entry.catalog_entry
177 || original.revision != entry.revision
178 || original.resources != entry.resources
179 {
180 return Err("loaded durable payload differs from original pin".into());
181 }
182 let descriptor = SkillActivationDescriptor {
183 catalog_revision: pinned.catalog_revision,
184 skill_revisions: pinned
185 .skills
186 .iter()
187 .map(|(id, entry)| (id.clone(), entry.revision))
188 .collect(),
189 selected_skill_mode: pinned.selected_skill_mode.clone(),
190 };
191 if !validate_pinned_activation_metadata(metadata, Some(&descriptor), Some(&active.id))? {
192 return Ok(unsupported("legacy lazy pin has no validated generation"));
193 }
194 if let Some(ids) = decode::<Vec<String>>(metadata, SKILL_RUNTIME_SELECTED_SKILL_IDS_KEY)? {
195 let unique = ids.iter().collect::<BTreeSet<_>>();
196 if unique.len() != ids.len() || unique != descriptor.skill_revisions.keys().collect() {
197 return Err("populated selected IDs contradict original snapshot".into());
198 }
199 }
200 for key in [
201 SKILL_RUNTIME_SELECTED_SKILL_MODE_KEY,
202 SELECTED_SKILL_MODE_METADATA_KEY,
203 ] {
204 if metadata
205 .get(key)
206 .is_some_and(|mode| Some(mode.as_str()) != pinned.selected_skill_mode.as_deref())
207 {
208 return Err("populated historical mode contradicts loaded snapshot".into());
209 }
210 }
211 if let Some(selection) = decode::<WorkflowSelection>(metadata, WORKFLOW_SELECTION_METADATA_KEY)?
212 {
213 if selection.id != active.id
214 || selection.source != active.source
215 || selection.revision != active.revision
216 || selection.args != active.args
217 {
218 return Err("populated typed selection contradicts loaded activation".into());
219 }
220 }
221 if let Some(ids) = decode::<Vec<String>>(metadata, LOADED_SKILL_IDS_METADATA_KEY)? {
222 if !ids.contains(&active.id) {
223 return Err("loaded IDs contradict active Instruction".into());
224 }
225 }
226 if metadata
227 .get(LAST_LOADED_SKILL_ID_METADATA_KEY)
228 .is_some_and(|id| id != &active.id)
229 {
230 return Err("last loaded ID contradicts active Instruction".into());
231 }
232 if let Some(event) = decode::<Value>(metadata, WORKFLOW_ACTIVATION_EVENT_METADATA_KEY)? {
233 if event["type"] != "workflow.activated"
234 || event["workflow_id"] != active.id
235 || event["revision"].as_u64() != Some(active.revision)
236 {
237 return Err("populated activation event contradicts loaded Instruction".into());
238 }
239 }
240
241 let mut anchors = Vec::new();
242 for (index, message) in messages
243 .iter()
244 .enumerate()
245 .filter(|(_, message)| message.role == Role::Assistant)
246 {
247 for call in message
248 .tool_calls
249 .iter()
250 .flatten()
251 .filter(|call| call.function.name == "load_skill")
252 {
253 if call.function.arguments.len() > MAX_DURABLE_WORKFLOW_ACTIVATION_BYTES {
254 return Err("legacy load arguments exceed bounds".into());
255 }
256 let args: Value = serde_json::from_str(&call.function.arguments)
257 .map_err(|error| error.to_string())?;
258 if args["skill_id"].as_str() != Some(&active.id) {
259 continue;
260 }
261 if call.id.is_empty()
262 || messages
263 .iter()
264 .flat_map(|m| m.tool_calls.iter().flatten())
265 .filter(|other| other.id == call.id)
266 .count()
267 != 1
268 {
269 return Err("legacy load tool-call ID is empty or reused".into());
270 }
271 let results = messages
272 .iter()
273 .enumerate()
274 .filter(|(_, result)| result.tool_call_id.as_deref() == Some(&call.id))
275 .collect::<Vec<_>>();
276 if results.len() != 1 {
277 return Ok(unsupported("load receipt is missing or ambiguous"));
278 }
279 let (receipt_index, receipt) = results[0];
280 if receipt.role != Role::Tool
281 || receipt_index <= index
282 || receipt.tool_success != Some(true)
283 {
284 continue;
285 }
286 if receipt.content.len() > MAX_DURABLE_WORKFLOW_ACTIVATION_BYTES {
287 return Err("legacy load receipt exceeds bounds".into());
288 }
289 let receipt_value: Value =
290 serde_json::from_str(&receipt.content).map_err(|error| error.to_string())?;
291 if receipt_value["activation_status"] != "active" {
292 continue;
293 }
294 if receipt_value["skill_id"] != active.id
295 || receipt_value["revision"].as_u64() != Some(active.revision)
296 || receipt_value["source"] != active.source.as_str()
297 || receipt_value["kind"] != "instruction"
298 {
299 return Err("successful active receipt contradicts typed Instruction".into());
300 }
301 anchors.push((index, receipt_index, receipt));
302 }
303 }
304 if anchors.len() != 1 {
305 return Ok(unsupported(
306 "five-field load receipt cannot identify a unique episode",
307 ));
308 }
309 let (call_index, _, receipt) = anchors[0];
310 if receipt.id.is_empty()
311 || messages
312 .iter()
313 .filter(|message| message.id == receipt.id)
314 .count()
315 != 1
316 || messages[call_index].created_at > active.activated_at
317 || receipt.created_at < active.activated_at
318 {
319 return Ok(unsupported(
320 "load receipt cannot bracket the typed activation episode",
321 ));
322 }
323 let calls = messages[call_index]
324 .tool_calls
325 .as_ref()
326 .ok_or("originating batch is missing")?;
327 let mut pending = calls
328 .iter()
329 .map(|call| call.id.as_str())
330 .collect::<BTreeSet<_>>();
331 if pending.len() != calls.len() || pending.contains("") {
332 return Err("originating batch has reused tool IDs".into());
333 }
334 for call in calls {
335 if messages
336 .iter()
337 .flat_map(|m| m.tool_calls.iter().flatten())
338 .filter(|other| other.id == call.id)
339 .count()
340 != 1
341 || messages
342 .iter()
343 .filter(|m| m.tool_call_id.as_deref() == Some(&call.id))
344 .count()
345 != 1
346 {
347 return Err("originating mixed batch has missing or reused results".into());
348 }
349 }
350 let mut closed = None;
351 for (index, result) in messages.iter().enumerate().skip(call_index + 1) {
352 if result.role != Role::Tool {
353 return Ok(unsupported("originating mixed batch is incomplete"));
354 }
355 if !pending.remove(result.tool_call_id.as_deref().unwrap_or("")) {
356 return Err("foreign result interrupts originating batch".into());
357 }
358 if pending.is_empty() {
359 closed = Some(index);
360 break;
361 }
362 }
363 let Some(closed) = closed else {
364 return Ok(unsupported("originating mixed batch has not closed"));
365 };
366 let dynamic = active
369 .dynamic_context
370 .iter()
371 .map(|block| {
372 serde_json::json!({
373 "provider_id": block.provider_id, "provenance": block.provenance,
374 "status": block.status, "content": block.content, "diagnostic": block.diagnostic,
375 })
376 })
377 .collect::<Vec<_>>();
378 let content = format!("workflow_id: {}\nsource: {:?}\nrevision: {}\nargs: {}\ncontext_fingerprint: {}\n\n### Instructions\n{}\n\n### Dynamic Context\n{}",
379 active.id, active.source, active.revision, active.args,
380 active.context_fingerprint.as_deref().unwrap_or("unavailable"), entry.definition.prompt,
381 serde_json::to_string(&dynamic).map_err(|error| error.to_string())?);
382 if content.len() > MAX_DURABLE_WORKFLOW_ACTIVATION_BYTES {
383 return Err("historical projection exceeds 512 KiB".into());
384 }
385 let mut hash = Sha256::new();
386 hash.update((receipt.id.len() as u64).to_be_bytes());
387 hash.update(receipt.id.as_bytes());
388 hash.update(content.as_bytes());
389 let id = hex::encode(hash.finalize());
390 let existing = messages
391 .iter()
392 .enumerate()
393 .filter(|(_, message)| message.id == id)
394 .collect::<Vec<_>>();
395 let mut message = Message::assistant(content, None);
396 message.id = id;
397 message.created_at = receipt.created_at;
398 if !existing.is_empty()
399 && (existing.len() != 1
400 || existing[0].0 != closed + 1
401 || serde_json::to_value(existing[0].1).map_err(|error| error.to_string())?
402 != serde_json::to_value(&message).map_err(|error| error.to_string())?)
403 {
404 return Err("deterministic ordinary history identity collides".into());
405 }
406 let remove_metadata = OBSOLETE_INSTRUCTION_KEYS
407 .iter()
408 .filter(|key| metadata.contains_key(**key))
409 .map(|key| (*key).to_owned())
410 .collect();
411 Ok(LegacySkillHistoryPlan {
412 insert_after: Some(closed),
413 message: existing.is_empty().then_some(message),
414 remove_metadata,
415 unsupported: None,
416 })
417}
418
419#[cfg(test)]
420mod tests {
421 use super::*;
422 use crate::session_app::skill_input::tests::{selection, snapshot};
423 use bamboo_agent_core::{FunctionCall, Session, ToolCall};
424 use bamboo_skills::activation::{DynamicContextBlock, WorkflowInvokedBy};
425 use chrono::{Duration, Utc};
426 use serde_json::json;
427
428 fn call(id: &str, name: &str, args: Value) -> ToolCall {
429 ToolCall {
430 id: id.into(),
431 tool_type: "function".into(),
432 function: FunctionCall {
433 name: name.into(),
434 arguments: args.to_string(),
435 },
436 }
437 }
438
439 fn fixture() -> (
440 Vec<Message>,
441 HashMap<String, String>,
442 DurableWorkflowActivation,
443 ) {
444 let now = Utc::now();
445 let active = ActiveWorkflow {
446 id: "review".into(),
447 source: bamboo_skills::WorkflowSource::Workspace,
448 revision: 7,
449 kind: WorkflowKind::Instruction,
450 args: selection().args,
451 invoked_by: WorkflowInvokedBy::User,
452 activated_at: now,
453 status: WorkflowActivationStatus::Active,
454 diagnostic: None,
455 context_fingerprint: Some("immutable-fingerprint".into()),
456 dynamic_context: vec![DynamicContextBlock {
457 provider_id: "old-provider".into(),
458 tool: "old-tool".into(),
459 provenance: "historical provenance".into(),
460 generated_at: now - Duration::seconds(1),
461 expires_at: None,
462 status: WorkflowActivationStatus::Active,
463 stop_on_failure: false,
464 content: "PRIVATE old dynamic context".into(),
465 diagnostic: None,
466 }],
467 };
468 let durable = DurableWorkflowActivation {
469 active,
470 snapshot: snapshot(),
471 };
472 let mut metadata = HashMap::from([
473 (
474 ACTIVE_WORKFLOW_METADATA_KEY.into(),
475 serde_json::to_string(&durable.active).unwrap(),
476 ),
477 (
478 ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY.into(),
479 serde_json::to_string(&durable).unwrap(),
480 ),
481 (
482 SKILL_RUNTIME_PINNED_SNAPSHOT_KEY.into(),
483 serde_json::to_string(&durable.snapshot).unwrap(),
484 ),
485 (
486 SKILL_RUNTIME_SELECTED_CATALOG_KEY.into(),
487 serde_json::to_string(&vec![durable.snapshot.skills["review"]
488 .catalog_entry
489 .clone()])
490 .unwrap(),
491 ),
492 (SKILL_RUNTIME_ACTIVATION_GENERATION_KEY.into(), "9".into()),
493 (
494 SKILL_RUNTIME_SELECTED_SKILL_REVISIONS_KEY.into(),
495 json!({"review":7}).to_string(),
496 ),
497 (SKILL_RUNTIME_SELECTED_SKILL_MODE_KEY.into(), "code".into()),
498 (
499 WORKFLOW_SELECTION_METADATA_KEY.into(),
500 serde_json::to_string(&selection()).unwrap(),
501 ),
502 (
503 LOADED_SKILL_IDS_METADATA_KEY.into(),
504 json!(["review"]).to_string(),
505 ),
506 (LAST_LOADED_SKILL_ID_METADATA_KEY.into(), "review".into()),
507 (
508 SELECTED_SKILL_IDS_METADATA_KEY.into(),
509 json!(["configured-other"]).to_string(),
510 ),
511 (
512 "workflow.run_ids.v1".into(),
513 json!(["orchestration-run"]).to_string(),
514 ),
515 ]);
516 metadata.insert(
517 WORKFLOW_LAST_DYNAMIC_CONTEXT_METADATA_KEY.into(),
518 serde_json::to_string(&durable.active.dynamic_context).unwrap(),
519 );
520 let mut assistant = Message::assistant(
521 "original assistant commentary",
522 Some(vec![
523 call("load-id", "load_skill", json!({"skill_id":"review"})),
524 call("other-id", "read", json!({"file":"other"})),
525 ]),
526 );
527 assistant.created_at = now - Duration::seconds(1);
528 let mut receipt=Message::tool_result("load-id",json!({"skill_id":"review","revision":7,"source":"workspace","kind":"instruction","activation_status":"active"}).to_string());
529 receipt.created_at = now + Duration::seconds(1);
530 let mut other = Message::tool_result("other-id", "original mixed result");
531 other.created_at = now + Duration::seconds(2);
532 (
533 vec![
534 Message::user("original User input"),
535 assistant,
536 receipt,
537 other,
538 Message::assistant("later answer", None),
539 ],
540 metadata,
541 durable,
542 )
543 }
544
545 fn sync_durable(metadata: &mut HashMap<String, String>, durable: &DurableWorkflowActivation) {
546 metadata.insert(
547 ACTIVE_WORKFLOW_METADATA_KEY.into(),
548 serde_json::to_string(&durable.active).unwrap(),
549 );
550 metadata.insert(
551 ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY.into(),
552 serde_json::to_string(durable).unwrap(),
553 );
554 metadata.insert(
555 SKILL_RUNTIME_PINNED_SNAPSHOT_KEY.into(),
556 serde_json::to_string(&durable.snapshot).unwrap(),
557 );
558 metadata.insert(
559 SKILL_RUNTIME_SELECTED_CATALOG_KEY.into(),
560 serde_json::to_string(&vec![durable.snapshot.skills["review"]
561 .catalog_entry
562 .clone()])
563 .unwrap(),
564 );
565 }
566
567 #[test]
568 fn legacy_skill_history_projects_loaded_contents_after_complete_mixed_batch() {
569 let (messages, metadata, _) = fixture();
570 let originals = serde_json::to_value(&messages).unwrap();
571 let original_metadata = metadata.clone();
572 let plan = plan_legacy_skill_history(&messages, &metadata).unwrap();
573 assert_eq!(plan.insert_after, Some(3));
574 assert!(plan.unsupported.is_none());
575 let message = plan.message.unwrap();
576 assert_eq!(message.role, Role::Assistant);
577 assert_eq!(message.created_at, messages[2].created_at);
578 assert!(message.tool_calls.is_none());
579 assert!(message.tool_call_id.is_none());
580 assert!(message
581 .content
582 .contains("### Instructions\nPRIVATE Instructions"));
583 assert!(message.content.contains("PRIVATE old dynamic context"));
584 assert!(message.content.contains("historical provenance"));
585 assert!(message.content.contains("src/main.rs"));
586 assert!(message.content.contains("immutable-fingerprint"));
587 assert!(!message.content.contains("RESOURCE_MUST_NOT_APPEAR"));
588 let mut session = Session::new("history-fixture", "model");
589 session.messages = messages.clone();
590 session.metadata = metadata.clone();
591 let block =
592 super::super::prompt_envelope::build_active_workflow_context_block(&session).unwrap();
593 assert_eq!(message.content, block.content);
594 assert!(plan
595 .remove_metadata
596 .contains(&ACTIVE_WORKFLOW_METADATA_KEY.into()));
597 assert!(!plan
598 .remove_metadata
599 .contains(&SELECTED_SKILL_IDS_METADATA_KEY.into()));
600 assert!(!plan.remove_metadata.contains(&"workflow.run_ids.v1".into()));
601 assert_eq!(serde_json::to_value(messages).unwrap(), originals);
602 assert_eq!(metadata, original_metadata);
603 }
604
605 #[test]
606 fn legacy_skill_history_retry_validates_anchor_and_exact_ordinary_identity() {
607 let (messages, metadata, _) = fixture();
608 let first = plan_legacy_skill_history(&messages, &metadata).unwrap();
609 let again = plan_legacy_skill_history(&messages, &metadata).unwrap();
610 assert_eq!(
611 serde_json::to_value(&first.message).unwrap(),
612 serde_json::to_value(again.message).unwrap()
613 );
614 let mut applied = messages.clone();
615 applied.insert(first.insert_after.unwrap() + 1, first.message.unwrap());
616 let replay = plan_legacy_skill_history(&applied, &metadata).unwrap();
617 assert!(replay.message.is_none());
618 assert!(replay.unsupported.is_none());
619 assert_eq!(applied.len(), messages.len() + 1);
620 assert_eq!(
621 serde_json::to_value(&applied[..4]).unwrap(),
622 serde_json::to_value(&messages[..4]).unwrap()
623 );
624 let mut collision = applied.clone();
625 collision[4].content.push_str("collision");
626 assert!(plan_legacy_skill_history(&collision, &metadata).is_err());
627 let mut wrong_time = applied.clone();
628 wrong_time[4].created_at += Duration::seconds(1);
629 assert!(plan_legacy_skill_history(&wrong_time, &metadata).is_err());
630 applied[2].tool_success = Some(false);
631 assert!(
632 plan_legacy_skill_history(&applied, &metadata)
633 .unwrap()
634 .unsupported
635 .is_some(),
636 "derived ID never bypasses original anchor"
637 );
638 let mut cloned_metadata = metadata.clone();
639 for key in replay.remove_metadata {
640 cloned_metadata.remove(&key);
641 }
642 assert!(plan_legacy_skill_history(&applied, &cloned_metadata)
643 .unwrap()
644 .message
645 .is_none());
646 }
647
648 #[test]
649 fn legacy_skill_history_pending_degraded_and_lazy_pins_do_not_become_loaded() {
650 let (messages, metadata, _) = fixture();
651 let mut pending = metadata.clone();
652 pending.remove(ACTIVE_WORKFLOW_METADATA_KEY);
653 assert!(plan_legacy_skill_history(&messages, &pending)
654 .unwrap()
655 .unsupported
656 .is_some());
657 let mut lazy = metadata.clone();
658 lazy.remove(SKILL_RUNTIME_ACTIVATION_GENERATION_KEY);
659 assert!(plan_legacy_skill_history(&messages, &lazy)
660 .unwrap()
661 .unsupported
662 .is_some());
663 let mut degraded = messages.clone();
664 let mut value: Value = serde_json::from_str(°raded[2].content).unwrap();
665 value["activation_status"] = json!("degraded");
666 degraded[2].content = value.to_string();
667 assert!(degraded[2].tool_success.unwrap());
668 assert!(plan_legacy_skill_history(°raded, &metadata)
669 .unwrap()
670 .unsupported
671 .is_some());
672 for success in [None, Some(false)] {
673 let mut missing = messages.clone();
674 missing[2].tool_success = success;
675 assert!(plan_legacy_skill_history(&missing, &metadata)
676 .unwrap()
677 .unsupported
678 .is_some());
679 }
680 let mut only_ids = HashMap::new();
681 only_ids.insert(
682 LOADED_SKILL_IDS_METADATA_KEY.into(),
683 json!(["review"]).to_string(),
684 );
685 assert!(plan_legacy_skill_history(&messages, &only_ids)
686 .unwrap()
687 .unsupported
688 .is_some());
689 }
690
691 #[test]
692 fn legacy_skill_history_rejects_populated_contradictions_atomically() {
693 let (messages, metadata, _) = fixture();
694 for (key,value) in [
695 (SKILL_RUNTIME_ACTIVATION_GENERATION_KEY,"10"),
696 (SKILL_RUNTIME_SELECTED_SKILL_REVISIONS_KEY,"{\"review\":8}"),
697 (SKILL_RUNTIME_SELECTED_SKILL_IDS_KEY,"[\"foreign\"]"),
698 (SKILL_RUNTIME_SELECTED_SKILL_IDS_KEY,"[\"review\",\"review\"]"),
699 (SKILL_RUNTIME_SELECTED_SKILL_MODE_KEY,"foreign-mode"),
700 (SELECTED_SKILL_MODE_METADATA_KEY,"foreign-mode"),
701 (LAST_LOADED_SKILL_ID_METADATA_KEY,"foreign"),
702 (LOADED_SKILL_IDS_METADATA_KEY,"[]"),
703 (WORKFLOW_SELECTION_METADATA_KEY,"{\"id\":\"review\",\"source\":\"user\",\"revision\":7,\"args\":{\"target\":\"src/main.rs\"}}"),
704 (ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY,"{"),
705 (SKILL_RUNTIME_SELECTED_CATALOG_KEY,"[]"),
706 (WORKFLOW_ACTIVATION_EVENT_METADATA_KEY,"{\"type\":\"workflow.activated\",\"workflow_id\":\"foreign\",\"revision\":7}"),
707 ] {
708 let mut changed=metadata.clone(); changed.insert(key.into(),value.into()); let before=changed.clone();
709 assert!(plan_legacy_skill_history(&messages,&changed).is_err(),"{key}"); assert_eq!(changed,before);
710 }
711 for field in ["revision", "source", "kind"] {
712 let mut changed = messages.clone();
713 let mut receipt: Value = serde_json::from_str(&changed[2].content).unwrap();
714 receipt[field] = match field {
715 "revision" => json!(8),
716 "source" => json!("user"),
717 _ => json!("orchestration"),
718 };
719 changed[2].content = receipt.to_string();
720 assert!(
721 plan_legacy_skill_history(&changed, &metadata).is_err(),
722 "{field}"
723 );
724 }
725 }
726
727 #[test]
728 fn legacy_skill_history_reused_ids_ambiguous_episodes_and_unclosed_batches_never_guess() {
729 let (messages, metadata, _) = fixture();
730 let mut duplicate = messages.clone();
731 duplicate.push(messages[2].clone());
732 assert!(plan_legacy_skill_history(&duplicate, &metadata)
733 .unwrap()
734 .unsupported
735 .is_some());
736 let mut reused = messages.clone();
737 reused.push(messages[1].clone());
738 assert!(plan_legacy_skill_history(&reused, &metadata).is_err());
739 let mut repeated = messages.clone();
740 let mut assistant = messages[1].clone();
741 assistant.tool_calls = Some(vec![call(
742 "later-load",
743 "load_skill",
744 json!({"skill_id":"review"}),
745 )]);
746 assistant.id = "later-assistant".into();
747 let mut receipt = messages[2].clone();
748 receipt.id = "later-receipt".into();
749 receipt.tool_call_id = Some("later-load".into());
750 repeated.extend([assistant, receipt]);
751 assert!(plan_legacy_skill_history(&repeated, &metadata)
752 .unwrap()
753 .unsupported
754 .is_some());
755 let mut interrupted = messages.clone();
756 interrupted.insert(3, Message::user("interrupt"));
757 assert!(plan_legacy_skill_history(&interrupted, &metadata)
758 .unwrap()
759 .unsupported
760 .is_some());
761 let mut missing = messages.clone();
762 missing.remove(3);
763 assert!(plan_legacy_skill_history(&missing, &metadata).is_err());
764 let mut foreign = messages.clone();
765 foreign.insert(3, Message::tool_result("foreign-id", "foreign result"));
766 assert!(plan_legacy_skill_history(&foreign, &metadata).is_err());
767 let mut wrong_epoch = messages.clone();
768 wrong_epoch[1].created_at = messages[2].created_at + Duration::seconds(1);
769 assert!(plan_legacy_skill_history(&wrong_epoch, &metadata)
770 .unwrap()
771 .unsupported
772 .is_some());
773 let mut reorder = messages.clone();
774 reorder.swap(2, 3);
775 let plan = plan_legacy_skill_history(&reorder, &metadata).unwrap();
776 assert_eq!(plan.insert_after, Some(3));
777 }
778
779 #[test]
780 fn legacy_skill_history_keeps_loaded_body_beyond_8000_and_bounds_original_decoder() {
781 let (messages, mut metadata, mut durable) = fixture();
782 durable
783 .snapshot
784 .skills
785 .get_mut("review")
786 .unwrap()
787 .definition
788 .prompt = "OLD🙂".repeat(3_000);
789 sync_durable(&mut metadata, &durable);
790 let result = plan_legacy_skill_history(&messages, &metadata)
791 .unwrap()
792 .message
793 .unwrap();
794 assert!(result
795 .content
796 .contains(&durable.snapshot.skills["review"].definition.prompt));
797 assert!(!result.content.contains("truncated"));
798 metadata.insert(
799 ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY.into(),
800 " ".repeat(MAX_DURABLE_WORKFLOW_ACTIVATION_BYTES + 1),
801 );
802 assert!(plan_legacy_skill_history(&messages, &metadata)
803 .unwrap_err()
804 .contains("512 KiB"));
805 }
806
807 #[test]
808 fn legacy_skill_history_bounds_valid_populated_revision_json_before_native_validation() {
809 let (messages, mut metadata, _) = fixture();
810 let oversized = format!(
811 "{}{}",
812 json!({"review":7}),
813 " ".repeat(MAX_DURABLE_WORKFLOW_ACTIVATION_BYTES)
814 );
815 metadata.insert(SKILL_RUNTIME_SELECTED_SKILL_REVISIONS_KEY.into(), oversized);
816 assert!(
817 plan_legacy_skill_history(&messages, &metadata).is_err(),
818 "valid oversized JSON must not bypass the historical byte bound"
819 );
820 }
821
822 #[test]
823 fn legacy_skill_history_does_not_mutate_orchestration_or_invent_mode_from_receipt() {
824 let (messages, mut metadata, mut durable) = fixture();
825 durable.active.kind = WorkflowKind::Orchestration;
826 sync_durable(&mut metadata, &durable);
827 let before = metadata.clone();
828 let plan = plan_legacy_skill_history(&messages, &metadata).unwrap();
829 assert!(plan.message.is_none());
830 assert!(plan.remove_metadata.is_empty());
831 assert_eq!(metadata, before);
832 let (messages, mut metadata, durable) = fixture();
833 let mut mixed = durable.snapshot.clone();
834 let mut other = mixed.skills["review"].clone();
835 other.definition.id = "orchestration".into();
836 other.catalog_entry.id = "orchestration".into();
837 other.catalog_entry.kind = WorkflowKind::Orchestration;
838 mixed.skills.insert("orchestration".into(), other);
839 metadata.insert(
840 SKILL_RUNTIME_PINNED_SNAPSHOT_KEY.into(),
841 serde_json::to_string(&mixed).unwrap(),
842 );
843 metadata.insert(
844 SKILL_RUNTIME_SELECTED_CATALOG_KEY.into(),
845 serde_json::to_string(
846 &mixed
847 .skills
848 .values()
849 .map(|entry| &entry.catalog_entry)
850 .collect::<Vec<_>>(),
851 )
852 .unwrap(),
853 );
854 metadata.insert(
855 SKILL_RUNTIME_SELECTED_SKILL_REVISIONS_KEY.into(),
856 json!({"review":7,"orchestration":7}).to_string(),
857 );
858 let plan = plan_legacy_skill_history(&messages, &metadata).unwrap();
859 assert!(plan.message.is_none());
860 assert!(plan.remove_metadata.is_empty());
861 let (messages, mut metadata, mut durable) = fixture();
862 durable.snapshot.selected_skill_mode = None;
863 metadata.insert(
864 ACTIVE_WORKFLOW_SNAPSHOT_METADATA_KEY.into(),
865 serde_json::to_string(&durable).unwrap(),
866 );
867 assert!(
868 plan_legacy_skill_history(&messages, &metadata).is_err(),
869 "receipt cannot establish mode"
870 );
871 let (messages, mut metadata, mut durable) = fixture();
872 durable.active.args = json!({"target":42});
873 sync_durable(&mut metadata, &durable);
874 assert!(
875 plan_legacy_skill_history(&messages, &metadata).is_err(),
876 "typed args still validate schema"
877 );
878 let (messages, mut metadata, mut durable) = fixture();
879 durable.snapshot.catalog_revision = 0;
880 sync_durable(&mut metadata, &durable);
881 metadata.insert(SKILL_RUNTIME_ACTIVATION_GENERATION_KEY.into(), "0".into());
882 assert!(plan_legacy_skill_history(&messages, &metadata).is_err());
883 }
884}