1use serde::{Deserialize, Serialize};
8
9use super::driver::PlannedStep;
10use super::effect::{
11 ArchivePageOutEffect, CallProviderEffect, EffectKind, EffectKindTag, EvaluateMilestoneEffect,
12 ExecuteToolsEffect, KernelEffect, LoadPayloadEffect, PersistMemoryEffect,
13 PreemptTasksEffect, QueryMemoryEffect, RequestApprovalEffect, SpawnTasksEffect,
14};
15use super::scalar::EffectId;
16
17#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
19#[serde(deny_unknown_fields)]
20pub struct PublishedEffectRef {
21 pub effect_id: EffectId,
22 pub kind: EffectKindTag,
23}
24
25#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
28#[serde(tag = "kind", rename_all = "snake_case")]
29pub enum CanonicalHostAction {
30 CallProvider {
31 effect_id: EffectId,
32 causation_input_id: super::scalar::InputId,
33 payload: CallProviderEffect,
34 },
35 ExecuteTools {
36 effect_id: EffectId,
37 causation_input_id: super::scalar::InputId,
38 payload: ExecuteToolsEffect,
39 },
40 RequestApproval {
41 effect_id: EffectId,
42 causation_input_id: super::scalar::InputId,
43 payload: RequestApprovalEffect,
44 },
45 SpawnTasks {
46 effect_id: EffectId,
47 causation_input_id: super::scalar::InputId,
48 payload: SpawnTasksEffect,
49 },
50 PreemptTasks {
51 effect_id: EffectId,
52 causation_input_id: super::scalar::InputId,
53 payload: PreemptTasksEffect,
54 },
55 PersistMemory {
56 effect_id: EffectId,
57 causation_input_id: super::scalar::InputId,
58 payload: PersistMemoryEffect,
59 },
60 QueryMemory {
61 effect_id: EffectId,
62 causation_input_id: super::scalar::InputId,
63 payload: QueryMemoryEffect,
64 },
65 ArchivePageOut {
66 effect_id: EffectId,
67 causation_input_id: super::scalar::InputId,
68 payload: ArchivePageOutEffect,
69 },
70 LoadPayload {
71 effect_id: EffectId,
72 causation_input_id: super::scalar::InputId,
73 payload: LoadPayloadEffect,
74 },
75 EvaluateMilestone {
76 effect_id: EffectId,
77 causation_input_id: super::scalar::InputId,
78 payload: EvaluateMilestoneEffect,
79 },
80}
81
82#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
83#[serde(tag = "state", content = "action", rename_all = "snake_case")]
84pub enum KernelProjection {
85 Idle,
86 Action(CanonicalHostAction),
87 Terminal(super::terminal::KernelTerminal),
88}
89
90#[derive(Debug, Clone, PartialEq, Eq)]
91pub struct ProjectionError {
92 pub message: String,
93}
94
95pub fn project_effect(effect: &KernelEffect) -> CanonicalHostAction {
97 let effect_id = effect.effect_id.clone();
98 let causation_input_id = effect.causation_input_id.clone();
99 match &effect.effect {
100 EffectKind::CallProvider(payload) => CanonicalHostAction::CallProvider {
101 effect_id,
102 causation_input_id,
103 payload: payload.clone(),
104 },
105 EffectKind::ExecuteTools(payload) => CanonicalHostAction::ExecuteTools {
106 effect_id,
107 causation_input_id,
108 payload: payload.clone(),
109 },
110 EffectKind::RequestApproval(payload) => CanonicalHostAction::RequestApproval {
111 effect_id,
112 causation_input_id,
113 payload: payload.clone(),
114 },
115 EffectKind::SpawnTasks(payload) => CanonicalHostAction::SpawnTasks {
116 effect_id,
117 causation_input_id,
118 payload: payload.clone(),
119 },
120 EffectKind::PreemptTasks(payload) => CanonicalHostAction::PreemptTasks {
121 effect_id,
122 causation_input_id,
123 payload: payload.clone(),
124 },
125 EffectKind::PersistMemory(payload) => CanonicalHostAction::PersistMemory {
126 effect_id,
127 causation_input_id,
128 payload: payload.clone(),
129 },
130 EffectKind::QueryMemory(payload) => CanonicalHostAction::QueryMemory {
131 effect_id,
132 causation_input_id,
133 payload: payload.clone(),
134 },
135 EffectKind::ArchivePageOut(payload) => CanonicalHostAction::ArchivePageOut {
136 effect_id,
137 causation_input_id,
138 payload: payload.clone(),
139 },
140 EffectKind::LoadPayload(payload) => CanonicalHostAction::LoadPayload {
141 effect_id,
142 causation_input_id,
143 payload: payload.clone(),
144 },
145 EffectKind::EvaluateMilestone(payload) => CanonicalHostAction::EvaluateMilestone {
146 effect_id,
147 causation_input_id,
148 payload: payload.clone(),
149 },
150 }
151}
152
153pub fn project_action(step: &PlannedStep) -> Result<KernelProjection, ProjectionError> {
156 project_pending_action(step.disposition.terminal(), step.disposition.effects())
157}
158
159pub fn current_effect(step: &PlannedStep) -> Option<&KernelEffect> {
162 step.disposition.effects().first()
163}
164
165pub fn project_planned_step_json(raw: &str) -> Result<String, ProjectionError> {
167 let step: PlannedStep = serde_json::from_str(raw).map_err(|error| ProjectionError {
168 message: format!("invalid planned step: {error}"),
169 })?;
170 let projection = project_action(&step)?;
171 serde_json::to_string(&projection).map_err(|error| ProjectionError {
172 message: format!("projection serialization failed: {error}"),
173 })
174}
175
176pub fn published_effects_manifest_json(raw: &str) -> Result<String, ProjectionError> {
179 let step: PlannedStep = serde_json::from_str(raw).map_err(|error| ProjectionError {
180 message: format!("invalid planned step: {error}"),
181 })?;
182 serde_json::to_string(&published_effects_manifest(&step)).map_err(|error| ProjectionError {
183 message: format!("manifest serialization failed: {error}"),
184 })
185}
186
187pub fn project_pending_action<'a, I>(
191 terminal: Option<&super::terminal::KernelTerminal>,
192 effects: I,
193) -> Result<KernelProjection, ProjectionError>
194where
195 I: IntoIterator<Item = &'a KernelEffect>,
196{
197 if let Some(terminal) = terminal {
198 return Ok(KernelProjection::Terminal(terminal.clone()));
199 }
200 match effects.into_iter().next() {
201 Some(effect) => Ok(KernelProjection::Action(project_effect(effect))),
202 None => Ok(KernelProjection::Idle),
203 }
204}
205
206pub fn published_effects_manifest(step: &PlannedStep) -> Vec<PublishedEffectRef> {
212 step.disposition.effects().iter().map(effect_ref).collect()
213}
214
215fn effect_ref(effect: &KernelEffect) -> PublishedEffectRef {
216 PublishedEffectRef {
217 effect_id: effect.effect_id.clone(),
218 kind: effect.tag(),
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use super::{
225 CanonicalHostAction, KernelProjection, project_action, project_effect,
226 project_pending_action, published_effects_manifest,
227 };
228 use crate::runtime::kernel::wire::{EffectKindTag, PlannedStep};
229
230 #[test]
231 fn manifest_preserves_multi_effect_publication_order() {
232 let fixture: serde_json::Value = serde_json::from_str(include_str!(
233 "../../../../../../tests/fixtures/abi/multi_effect_step.json"
234 ))
235 .expect("fixture JSON");
236 let step: PlannedStep =
237 serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
238
239 let manifest = published_effects_manifest(&step);
240
241 assert_eq!(manifest.len(), 2);
242 assert_eq!(
243 manifest[0].effect_id.as_str(),
244 "op-contract:step:9:effect:0"
245 );
246 assert_eq!(manifest[0].kind, EffectKindTag::QueryMemory);
247 assert_eq!(
248 manifest[1].effect_id.as_str(),
249 "op-contract:step:9:effect:1"
250 );
251 assert_eq!(manifest[1].kind, EffectKindTag::ExecuteTools);
252 }
253
254 #[test]
255 fn terminal_step_has_empty_manifest() {
256 let step = PlannedStep {
257 root_kind: None,
258 focus: None,
259 observations: Vec::new(),
260 disposition: crate::runtime::kernel::wire::StepDisposition::Terminal(
261 crate::runtime::kernel::wire::TerminalDisposition {
262 terminal: crate::runtime::kernel::wire::KernelTerminal::Cancelled(
263 crate::runtime::kernel::wire::CancelledTerminal {
264 reason: crate::runtime::kernel::wire::CancellationReason::HostShutdown,
265 usage: crate::runtime::kernel::wire::UsageReport {
266 input_tokens: crate::runtime::kernel::wire::WireU64::ZERO,
267 output_tokens: crate::runtime::kernel::wire::WireU64::ZERO,
268 turns: 0,
269 cached_input_tokens: None,
270 },
271 },
272 ),
273 },
274 ),
275 };
276
277 assert!(published_effects_manifest(&step).is_empty());
278 }
279
280 #[test]
281 fn projects_the_first_query_memory_effect_to_a_typed_action() {
282 let fixture: serde_json::Value = serde_json::from_str(include_str!(
283 "../../../../../../tests/fixtures/abi/multi_effect_step.json"
284 ))
285 .expect("fixture JSON");
286 let step: PlannedStep =
287 serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
288 let effect = step.disposition.effects().first().expect("first effect");
289
290 let action = project_effect(effect);
291
292 match action {
293 CanonicalHostAction::QueryMemory {
294 effect_id, payload, ..
295 } => {
296 assert_eq!(effect_id.as_str(), "op-contract:step:9:effect:0");
297 assert_eq!(payload.query.text, "past briefs");
298 assert_eq!(payload.requested_k, 4);
299 }
300 other => panic!("expected query_memory action, got {other:?}"),
301 }
302 }
303
304 #[test]
305 fn canonical_action_serialization_keeps_wire_kind_and_identity() {
306 let fixture: serde_json::Value = serde_json::from_str(include_str!(
307 "../../../../../../tests/fixtures/abi/multi_effect_step.json"
308 ))
309 .expect("fixture JSON");
310 let step: PlannedStep =
311 serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
312 let action = project_effect(step.disposition.effects().first().expect("effect"));
313 let value = serde_json::to_value(action).expect("action JSON");
314
315 assert_eq!(value["kind"], "query_memory");
316 assert_eq!(value["effect_id"], "op-contract:step:9:effect:0");
317 assert_eq!(value["causation_input_id"], "in-9");
318 assert_eq!(value["payload"]["requested_k"], 4);
319 assert_eq!(value["payload"]["query"]["text"], "past briefs");
320 }
321
322 #[test]
323 fn projection_serialization_has_explicit_state_tag() {
324 let fixture: serde_json::Value = serde_json::from_str(include_str!(
325 "../../../../../../tests/fixtures/abi/multi_effect_step.json"
326 ))
327 .expect("fixture JSON");
328 let step: PlannedStep =
329 serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
330 let value = serde_json::to_value(project_action(&step).expect("projection"))
331 .expect("projection JSON");
332
333 assert_eq!(value["state"], "action");
334 assert_eq!(value["action"]["kind"], "query_memory");
335 assert_eq!(value["action"]["effect_id"], "op-contract:step:9:effect:0");
336 }
337
338 #[test]
339 fn planned_step_json_bridge_matches_projection() {
340 let fixture: serde_json::Value = serde_json::from_str(include_str!(
341 "../../../../../../tests/fixtures/abi/multi_effect_step.json"
342 ))
343 .expect("fixture JSON");
344 let output = super::project_planned_step_json(
345 &serde_json::to_string(&fixture["planned_step"]).expect("planned step JSON"),
346 )
347 .expect("projection JSON");
348 let value: serde_json::Value = serde_json::from_str(&output).expect("projection");
349 assert_eq!(value["state"], "action");
350 assert_eq!(value["action"]["kind"], "query_memory");
351 }
352
353 #[test]
354 fn projection_selects_first_effect_and_distinguishes_idle() {
355 let fixture: serde_json::Value = serde_json::from_str(include_str!(
356 "../../../../../../tests/fixtures/abi/multi_effect_step.json"
357 ))
358 .expect("fixture JSON");
359 let step: PlannedStep =
360 serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
361
362 let projection = project_action(&step).expect("projection");
363 assert!(matches!(
364 projection,
365 KernelProjection::Action(CanonicalHostAction::QueryMemory { .. })
366 ));
367
368 let idle = PlannedStep {
369 root_kind: None,
370 focus: None,
371 observations: Vec::new(),
372 disposition: crate::runtime::kernel::wire::StepDisposition::Effects(Default::default()),
373 };
374 assert!(matches!(
375 project_action(&idle).expect("projection"),
376 KernelProjection::Idle
377 ));
378
379 let ordered = step.disposition.effects().iter();
380 assert!(matches!(
381 project_pending_action(None, ordered).expect("projection"),
382 KernelProjection::Action(CanonicalHostAction::QueryMemory { .. })
383 ));
384 }
385}