1use serde::{Deserialize, Serialize};
8
9use super::driver::PlannedStep;
10use super::effect::{
11 ArchivePageOutEffect, CallProviderEffect, EffectKind, EffectKindTag, EvaluateMilestoneEffect,
12 ExecuteToolsEffect, KernelEffect, LoadPayloadEffect, MeasurePromptEffect, 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 MeasurePrompt {
81 effect_id: EffectId,
82 causation_input_id: super::scalar::InputId,
83 payload: MeasurePromptEffect,
84 },
85}
86
87#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
88#[serde(tag = "state", content = "action", rename_all = "snake_case")]
89pub enum CurrentProjection {
90 Idle,
91 Action(CanonicalHostAction),
92 Terminal(super::terminal::KernelTerminal),
93}
94
95#[derive(Debug, Clone, PartialEq, Eq)]
96pub struct ProjectionError {
97 pub message: String,
98}
99
100pub fn project_effect(effect: &KernelEffect) -> CanonicalHostAction {
102 let effect_id = effect.effect_id.clone();
103 let causation_input_id = effect.causation_input_id.clone();
104 match &effect.effect {
105 EffectKind::CallProvider(payload) => CanonicalHostAction::CallProvider {
106 effect_id,
107 causation_input_id,
108 payload: payload.clone(),
109 },
110 EffectKind::ExecuteTools(payload) => CanonicalHostAction::ExecuteTools {
111 effect_id,
112 causation_input_id,
113 payload: payload.clone(),
114 },
115 EffectKind::RequestApproval(payload) => CanonicalHostAction::RequestApproval {
116 effect_id,
117 causation_input_id,
118 payload: payload.clone(),
119 },
120 EffectKind::SpawnTasks(payload) => CanonicalHostAction::SpawnTasks {
121 effect_id,
122 causation_input_id,
123 payload: payload.clone(),
124 },
125 EffectKind::PreemptTasks(payload) => CanonicalHostAction::PreemptTasks {
126 effect_id,
127 causation_input_id,
128 payload: payload.clone(),
129 },
130 EffectKind::PersistMemory(payload) => CanonicalHostAction::PersistMemory {
131 effect_id,
132 causation_input_id,
133 payload: payload.clone(),
134 },
135 EffectKind::QueryMemory(payload) => CanonicalHostAction::QueryMemory {
136 effect_id,
137 causation_input_id,
138 payload: payload.clone(),
139 },
140 EffectKind::ArchivePageOut(payload) => CanonicalHostAction::ArchivePageOut {
141 effect_id,
142 causation_input_id,
143 payload: payload.clone(),
144 },
145 EffectKind::LoadPayload(payload) => CanonicalHostAction::LoadPayload {
146 effect_id,
147 causation_input_id,
148 payload: payload.clone(),
149 },
150 EffectKind::EvaluateMilestone(payload) => CanonicalHostAction::EvaluateMilestone {
151 effect_id,
152 causation_input_id,
153 payload: payload.clone(),
154 },
155 EffectKind::MeasurePrompt(payload) => CanonicalHostAction::MeasurePrompt {
156 effect_id,
157 causation_input_id,
158 payload: payload.clone(),
159 },
160 }
161}
162
163pub fn project_current_action(step: &PlannedStep) -> Result<CurrentProjection, ProjectionError> {
166 project_current_pending_action(step.disposition.terminal(), step.disposition.effects())
167}
168
169pub fn current_effect(step: &PlannedStep) -> Option<&KernelEffect> {
172 step.disposition.effects().first()
173}
174
175pub fn project_planned_step_json(raw: &str) -> Result<String, ProjectionError> {
177 let step: PlannedStep = serde_json::from_str(raw).map_err(|error| ProjectionError {
178 message: format!("invalid planned step: {error}"),
179 })?;
180 let projection = project_current_action(&step)?;
181 serde_json::to_string(&projection).map_err(|error| ProjectionError {
182 message: format!("projection serialization failed: {error}"),
183 })
184}
185
186pub fn published_effects_manifest_json(raw: &str) -> Result<String, ProjectionError> {
189 let step: PlannedStep = serde_json::from_str(raw).map_err(|error| ProjectionError {
190 message: format!("invalid planned step: {error}"),
191 })?;
192 serde_json::to_string(&published_effects_manifest(&step)).map_err(|error| ProjectionError {
193 message: format!("manifest serialization failed: {error}"),
194 })
195}
196
197pub fn project_current_pending_action<'a, I>(
201 terminal: Option<&super::terminal::KernelTerminal>,
202 effects: I,
203) -> Result<CurrentProjection, ProjectionError>
204where
205 I: IntoIterator<Item = &'a KernelEffect>,
206{
207 if let Some(terminal) = terminal {
208 return Ok(CurrentProjection::Terminal(terminal.clone()));
209 }
210 match effects.into_iter().next() {
211 Some(effect) => Ok(CurrentProjection::Action(project_effect(effect))),
212 None => Ok(CurrentProjection::Idle),
213 }
214}
215
216pub fn published_effects_manifest(step: &PlannedStep) -> Vec<PublishedEffectRef> {
222 step.disposition.effects().iter().map(effect_ref).collect()
223}
224
225fn effect_ref(effect: &KernelEffect) -> PublishedEffectRef {
226 PublishedEffectRef {
227 effect_id: effect.effect_id.clone(),
228 kind: effect.tag(),
229 }
230}
231
232#[cfg(test)]
233mod tests {
234 use super::{
235 CanonicalHostAction, CurrentProjection, project_current_action,
236 project_current_pending_action, project_effect, published_effects_manifest,
237 };
238 use crate::runtime::kernel::wire::{EffectKindTag, PlannedStep};
239
240 #[test]
241 fn manifest_preserves_multi_effect_publication_order() {
242 let fixture: serde_json::Value = serde_json::from_str(include_str!(
243 "../../../../../../tests/fixtures/abi/multi_effect_step.json"
244 ))
245 .expect("fixture JSON");
246 let step: PlannedStep =
247 serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
248
249 let manifest = published_effects_manifest(&step);
250
251 assert_eq!(manifest.len(), 2);
252 assert_eq!(
253 manifest[0].effect_id.as_str(),
254 "op-contract:step:9:effect:0"
255 );
256 assert_eq!(manifest[0].kind, EffectKindTag::QueryMemory);
257 assert_eq!(
258 manifest[1].effect_id.as_str(),
259 "op-contract:step:9:effect:1"
260 );
261 assert_eq!(manifest[1].kind, EffectKindTag::ExecuteTools);
262 }
263
264 #[test]
265 fn terminal_step_has_empty_manifest() {
266 let step = PlannedStep {
267 root_kind: None,
268 focus: None,
269 observations: Vec::new(),
270 disposition: crate::runtime::kernel::wire::StepDisposition::Terminal(
271 crate::runtime::kernel::wire::TerminalDisposition {
272 terminal: crate::runtime::kernel::wire::KernelTerminal::Cancelled(
273 crate::runtime::kernel::wire::CancelledTerminal {
274 reason: crate::runtime::kernel::wire::CancellationReason::HostShutdown,
275 usage: crate::runtime::kernel::wire::UsageReport {
276 input_tokens: crate::runtime::kernel::wire::WireU64::ZERO,
277 output_tokens: crate::runtime::kernel::wire::WireU64::ZERO,
278 turns: 0,
279 cached_input_tokens: None,
280 },
281 },
282 ),
283 },
284 ),
285 };
286
287 assert!(published_effects_manifest(&step).is_empty());
288 }
289
290 #[test]
291 fn projects_the_first_query_memory_effect_to_a_typed_action() {
292 let fixture: serde_json::Value = serde_json::from_str(include_str!(
293 "../../../../../../tests/fixtures/abi/multi_effect_step.json"
294 ))
295 .expect("fixture JSON");
296 let step: PlannedStep =
297 serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
298 let effect = step.disposition.effects().first().expect("first effect");
299
300 let action = project_effect(effect);
301
302 match action {
303 CanonicalHostAction::QueryMemory {
304 effect_id, payload, ..
305 } => {
306 assert_eq!(effect_id.as_str(), "op-contract:step:9:effect:0");
307 assert_eq!(payload.query.text, "past briefs");
308 assert_eq!(payload.requested_k, 4);
309 }
310 other => panic!("expected query_memory action, got {other:?}"),
311 }
312 }
313
314 #[test]
315 fn canonical_action_serialization_keeps_wire_kind_and_identity() {
316 let fixture: serde_json::Value = serde_json::from_str(include_str!(
317 "../../../../../../tests/fixtures/abi/multi_effect_step.json"
318 ))
319 .expect("fixture JSON");
320 let step: PlannedStep =
321 serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
322 let action = project_effect(step.disposition.effects().first().expect("effect"));
323 let value = serde_json::to_value(action).expect("action JSON");
324
325 assert_eq!(value["kind"], "query_memory");
326 assert_eq!(value["effect_id"], "op-contract:step:9:effect:0");
327 assert_eq!(value["causation_input_id"], "in-9");
328 assert_eq!(value["payload"]["requested_k"], 4);
329 assert_eq!(value["payload"]["query"]["text"], "past briefs");
330 }
331
332 #[test]
333 fn current_projection_serialization_has_explicit_state_tag() {
334 let fixture: serde_json::Value = serde_json::from_str(include_str!(
335 "../../../../../../tests/fixtures/abi/multi_effect_step.json"
336 ))
337 .expect("fixture JSON");
338 let step: PlannedStep =
339 serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
340 let value = serde_json::to_value(project_current_action(&step).expect("projection"))
341 .expect("projection JSON");
342
343 assert_eq!(value["state"], "action");
344 assert_eq!(value["action"]["kind"], "query_memory");
345 assert_eq!(value["action"]["effect_id"], "op-contract:step:9:effect:0");
346 }
347
348 #[test]
349 fn planned_step_json_bridge_matches_current_projection() {
350 let fixture: serde_json::Value = serde_json::from_str(include_str!(
351 "../../../../../../tests/fixtures/abi/multi_effect_step.json"
352 ))
353 .expect("fixture JSON");
354 let output = super::project_planned_step_json(
355 &serde_json::to_string(&fixture["planned_step"]).expect("planned step JSON"),
356 )
357 .expect("projection JSON");
358 let value: serde_json::Value = serde_json::from_str(&output).expect("projection");
359 assert_eq!(value["state"], "action");
360 assert_eq!(value["action"]["kind"], "query_memory");
361 }
362
363 #[test]
364 fn current_projection_selects_first_effect_and_distinguishes_idle() {
365 let fixture: serde_json::Value = serde_json::from_str(include_str!(
366 "../../../../../../tests/fixtures/abi/multi_effect_step.json"
367 ))
368 .expect("fixture JSON");
369 let step: PlannedStep =
370 serde_json::from_value(fixture["planned_step"].clone()).expect("planned step");
371
372 let projection = project_current_action(&step).expect("projection");
373 assert!(matches!(
374 projection,
375 CurrentProjection::Action(CanonicalHostAction::QueryMemory { .. })
376 ));
377
378 let idle = PlannedStep {
379 root_kind: None,
380 focus: None,
381 observations: Vec::new(),
382 disposition: crate::runtime::kernel::wire::StepDisposition::Effects(Default::default()),
383 };
384 assert!(matches!(
385 project_current_action(&idle).expect("projection"),
386 CurrentProjection::Idle
387 ));
388
389 let ordered = step.disposition.effects().iter();
390 assert!(matches!(
391 project_current_pending_action(None, ordered).expect("projection"),
392 CurrentProjection::Action(CanonicalHostAction::QueryMemory { .. })
393 ));
394 }
395}