Skip to main content

bamboo_engine/runtime/runner/session_setup/
legacy_skill_history.rs

1//! Pure loaded-only conversion plan, applied by defaults SDK runner setup.
2
3use 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    /// Insert after this original message, once the entire mixed batch closed.
44    pub insert_after: Option<usize>,
45    pub message: Option<Message>,
46    pub remove_metadata: Vec<String>,
47    /// Unsupported old forms retain all original messages and metadata.
48    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
75/// Validate original typed and receipt evidence on every invocation, including
76/// retries whose deterministic ordinary message already exists. Identity is
77/// only deduplication; it never substitutes for the loaded anchor.
78pub 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    // Same Bamboo WorkflowRuntime projection as prompt_envelope.rs, borrowing
367    // historical content. No current filesystem or immutable resource-map copy.
368    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(&degraded[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(&degraded, &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}