1use agent_client_protocol::schema::v1::{ContentBlock, SessionUpdate};
3use mj_core::activity::ActivityFacts;
4use mj_core::activity::verdict::*;
5use mj_core::config::HarnessKind;
6use std::sync::{Arc, Mutex};
7
8#[derive(Debug, Default)]
9struct TurnContextState {
10 generation: u64,
11 parent_activity: Option<std::time::Instant>,
12 decision_log: Option<mj_core::jev::DecisionLog>,
13 authorization: Option<mj_core::assessment::ContextHistory>,
14 user_prompt_tail: String,
15 message_id: Option<String>,
16 assistant_text_tail: String,
17 last_completed_message: String,
18 summary: crate::summary::TranscriptSummary,
19 background_commands: usize,
20 queued_commands: usize,
21 background_inventory: Vec<mj_core::relay::BackgroundCommand>,
22 native_agent_ids: Vec<String>,
23 session_id: String,
24}
25
26pub fn delivered_prompt_command_id(observation: &mj_core::relay::RelayObservation) -> Option<&str> {
29 use mj_core::relay::{RelayCommandOutcome, RelayObservation};
30 match observation {
31 RelayObservation::CommandStarted { command_id, .. } => Some(command_id),
32 RelayObservation::CommandCompleted {
33 outcome: RelayCommandOutcome::Steered { queued_command_id },
34 ..
35 } => Some(queued_command_id),
36 RelayObservation::MailboxEventsDelivered {
37 steering: Some(steering),
38 ..
39 } => Some(&steering.queued_prompt_id),
40 _ => None,
41 }
42}
43
44#[derive(Debug, Default, Clone)]
46pub struct TurnContext(Arc<Mutex<TurnContextState>>);
47
48impl TurnContext {
49 pub fn mark_parent_activity(&self) {
51 self.mark_parent_activity_at(std::time::Instant::now());
52 }
53
54 pub fn mark_parent_activity_at(&self, at: std::time::Instant) {
56 self.0
57 .lock()
58 .expect("turn context lock poisoned")
59 .parent_activity = Some(at);
60 }
61
62 pub fn parent_activity(&self) -> Option<std::time::Instant> {
63 self.0
64 .lock()
65 .expect("turn context lock poisoned")
66 .parent_activity
67 }
68
69 pub fn set_decision_log(&self, log: mj_core::jev::DecisionLog) {
70 self.0
71 .lock()
72 .expect("turn context lock poisoned")
73 .decision_log = Some(log);
74 }
75 pub fn decision_log(&self) -> Option<mj_core::jev::DecisionLog> {
76 self.0
77 .lock()
78 .expect("turn context lock poisoned")
79 .decision_log
80 .clone()
81 }
82 pub fn reset(&self, prompt: &str) {
83 let mut state = self.0.lock().expect("turn context lock poisoned");
84 let generation = state.generation.wrapping_add(1);
85 *state = TurnContextState {
86 generation,
87 parent_activity: Some(std::time::Instant::now()),
88 decision_log: state.decision_log.clone(),
89 authorization: state.authorization.clone(),
90 user_prompt_tail: tail(prompt, USER_PROMPT_BYTES),
91 background_commands: state.background_commands,
92 queued_commands: state.queued_commands,
93 background_inventory: std::mem::take(&mut state.background_inventory),
94 native_agent_ids: std::mem::take(&mut state.native_agent_ids),
95 session_id: std::mem::take(&mut state.session_id),
96 summary: std::mem::take(&mut state.summary),
97 ..Default::default()
98 };
99 }
100
101 pub fn observe_relay(
103 &self,
104 observation: &mj_core::relay::RelayObservation,
105 prompt: Option<&str>,
106 ) {
107 use mj_core::relay::{RelayCommandOutcome, RelayObservation};
108 match observation {
109 RelayObservation::CommandStarted { .. }
110 | RelayObservation::CommandCompleted {
111 outcome: RelayCommandOutcome::Steered { .. },
112 ..
113 }
114 | RelayObservation::MailboxEventsDelivered {
115 steering: Some(_), ..
116 } => {
117 if let Some(prompt) = prompt {
118 self.reset(prompt);
119 self.0
120 .lock()
121 .expect("turn context lock poisoned")
122 .summary
123 .push_user(prompt);
124 }
125 }
126 RelayObservation::SessionUpdate { update } => self.observe(update),
127 RelayObservation::TerminalOutput {
128 terminal_id,
129 output,
130 truncated,
131 exit_code,
132 signal,
133 } => {
134 self.observe_terminal(&mj_core::transcript::TerminalOutputRecord {
135 terminal_id: terminal_id.clone(),
136 output: output.clone(),
137 truncated: *truncated,
138 exit_code: *exit_code,
139 signal: signal.clone(),
140 });
141 }
142 RelayObservation::CommandCompleted {
143 outcome: RelayCommandOutcome::ContextCleared { .. },
144 ..
145 } => self.clear_history(),
146 _ => {}
147 }
148 }
149
150 pub fn invalidate(&self) {
152 let mut state = self.0.lock().expect("turn context lock poisoned");
153 state.generation = state.generation.wrapping_add(1);
154 }
155
156 pub fn generation(&self) -> u64 {
157 self.0
158 .lock()
159 .expect("turn context lock poisoned")
160 .generation
161 }
162
163 pub fn counts(&self) -> (usize, usize) {
164 let state = self.0.lock().expect("turn context lock poisoned");
165 (state.background_commands, state.queued_commands)
166 }
167
168 pub fn set_counts(&self, background_commands: usize, queued_commands: usize) {
169 let mut state = self.0.lock().expect("turn context lock poisoned");
170 if (state.background_commands, state.queued_commands)
171 != (background_commands, queued_commands)
172 {
173 state.generation = state.generation.wrapping_add(1);
174 state.background_commands = background_commands;
175 state.queued_commands = queued_commands;
176 }
177 }
178
179 pub fn set_session_id(&self, session_id: &str) {
180 self.0
181 .lock()
182 .expect("turn context lock poisoned")
183 .session_id = session_id.into();
184 }
185
186 pub fn session_id(&self) -> String {
187 self.0
188 .lock()
189 .expect("turn context lock poisoned")
190 .session_id
191 .clone()
192 }
193
194 pub fn set_authorization_context(
197 &self,
198 authorization: Option<mj_core::assessment::ContextHistory>,
199 ) {
200 let mut state = self.0.lock().expect("turn context lock poisoned");
201 if state.authorization != authorization {
202 state.authorization = authorization;
203 state.generation = state.generation.wrapping_add(1);
204 }
205 }
206
207 pub fn authorization_context(&self) -> Option<mj_core::assessment::ContextHistory> {
208 self.0
209 .lock()
210 .expect("turn context lock poisoned")
211 .authorization
212 .clone()
213 }
214
215 pub fn set_background_inventory(
217 &self,
218 mut commands: Vec<mj_core::relay::BackgroundCommand>,
219 mut native_agent_ids: Vec<String>,
220 ) {
221 commands.sort_by(|a, b| a.id.cmp(&b.id));
222 native_agent_ids.sort();
223 let mut state = self.0.lock().expect("turn context lock poisoned");
224 if state.background_inventory != commands || state.native_agent_ids != native_agent_ids {
225 state.background_inventory = commands;
226 state.native_agent_ids = native_agent_ids;
227 state.generation = state.generation.wrapping_add(1);
228 }
229 }
230
231 pub fn observe(&self, update: &SessionUpdate) {
232 let mut state = self.0.lock().expect("turn context lock poisoned");
233 state.parent_activity = Some(std::time::Instant::now());
234 if matches!(
235 update,
236 SessionUpdate::AgentMessageChunk(_)
237 | SessionUpdate::AgentThoughtChunk(_)
238 | SessionUpdate::ToolCall(_)
239 | SessionUpdate::ToolCallUpdate(_)
240 ) {
241 state.generation = state.generation.wrapping_add(1);
242 }
243 state.summary.observe(update);
244 match update {
245 SessionUpdate::AgentMessageChunk(chunk) => {
246 let id = chunk.message_id.as_ref().map(ToString::to_string);
247 if id != state.message_id {
248 complete_message(&mut state);
249 state.message_id = id;
250 }
251 if let ContentBlock::Text(content) = &chunk.content {
252 state
254 .assistant_text_tail
255 .push_str(&tail(&content.text, ASSISTANT_TEXT_BYTES));
256 state.assistant_text_tail =
257 tail(&state.assistant_text_tail, ASSISTANT_TEXT_BYTES);
258 }
259 }
260 SessionUpdate::ToolCall(_) => {
261 complete_message(&mut state);
262 }
263 SessionUpdate::ToolCallUpdate(_) | SessionUpdate::AgentThoughtChunk(_) => {
264 complete_message(&mut state)
265 }
266 _ => {}
267 }
268 }
269
270 pub fn observe_terminal(&self, record: &mj_core::transcript::TerminalOutputRecord) {
271 let mut state = self.0.lock().expect("turn context lock poisoned");
272 state.summary.observe_terminal(record);
273 state.generation = state.generation.wrapping_add(1);
274 }
275
276 pub fn mark_earlier_history_omitted(&self) {
277 self.0
278 .lock()
279 .expect("turn context lock poisoned")
280 .summary
281 .mark_earlier_history_omitted();
282 }
283
284 pub fn clear_history(&self) {
285 let mut state = self.0.lock().expect("turn context lock poisoned");
286 state.summary = Default::default();
287 state.user_prompt_tail.clear();
288 state.assistant_text_tail.clear();
289 state.last_completed_message.clear();
290 state.message_id = None;
291 state.authorization = None;
292 state.generation = state.generation.wrapping_add(1);
293 }
294
295 pub fn evidence(
296 &self,
297 harness: HarnessKind,
298 phase: TurnPhase,
299 facts: &ActivityFacts,
300 now_ms: i64,
301 ) -> TurnEvidence {
302 let state = self.0.lock().expect("turn context lock poisoned");
303 let summary = state.summary.latest_user_messages();
304 let last_assistant = state
305 .summary
306 .entries
307 .iter()
308 .rposition(|e| e.role == crate::summary::SummaryRole::Assistant);
309 let final_tool_calls = state
310 .summary
311 .entries
312 .iter()
313 .skip(last_assistant.map_or(0, |i| i + 1))
314 .filter(|e| e.role == crate::summary::SummaryRole::Tool)
315 .take(IN_FLIGHT_TOOLS)
316 .map(|e| mj_core::activity::verdict::ToolOutcome {
317 name: tail(
318 e.tool
319 .as_ref()
320 .and_then(|t| t.get("name"))
321 .and_then(|n| n.as_str())
322 .unwrap_or(&e.text),
323 TOOL_TITLE_BYTES,
324 ),
325 status: e
326 .tool
327 .as_ref()
328 .and_then(|t| t.get("status"))
329 .and_then(|s| s.as_str())
330 .unwrap_or("unknown")
331 .to_owned(),
332 })
333 .collect();
334 let mut evidence = TurnEvidence {
335 authorization: None,
336 final_tool_calls,
337 background: Vec::new(),
338 harness,
339 phase,
340 silent_for_s: facts
341 .last_acp_activity_at_ms
342 .map_or(0, |last| now_ms.saturating_sub(last).max(0) as u64 / 1000),
343 tools_in_flight: facts
344 .tools_in_flight
345 .iter()
346 .take(IN_FLIGHT_TOOLS)
347 .map(|tool| ToolEvidence {
348 title: tail(
349 &state
350 .summary
351 .entries
352 .iter()
353 .find(|e| e.id == tool.tool_call_id)
354 .map(|e| e.text.clone())
355 .unwrap_or_else(|| "unknown tool".into()),
356 TOOL_TITLE_BYTES,
357 ),
358 running_s: now_ms.saturating_sub(tool.started_at_ms).max(0) as u64 / 1000,
359 })
360 .collect(),
361 transcript_summary: summary.render(48 * 1024),
362 background_commands: facts.background_commands,
363 queued_commands: facts.queued_commands,
364 user_prompt_tail: state.user_prompt_tail.clone(),
365 assistant_text_tail: if state.assistant_text_tail.is_empty() {
366 state.last_completed_message.clone()
367 } else {
368 state.assistant_text_tail.clone()
369 },
370 completion: None,
371 };
372 let mut limit = 48 * 1024;
373 while serde_json::to_vec(&evidence)
374 .expect("serialize evidence")
375 .len()
376 > 60 * 1024
377 {
378 limit /= 2;
379 evidence.transcript_summary = summary.render(limit);
380 }
381 evidence
382 }
383}
384
385fn complete_message(state: &mut TurnContextState) {
386 if !state.assistant_text_tail.is_empty() {
387 state.last_completed_message = std::mem::take(&mut state.assistant_text_tail);
388 }
389 state.message_id = None;
390}
391
392fn tail(text: &str, maximum_bytes: usize) -> String {
393 let start = text.floor_char_boundary(text.len().saturating_sub(maximum_bytes));
394 let mut value = text[start..].to_owned();
395 mj_core::transcript::truncate_string_start(&mut value, maximum_bytes);
396 value
397}
398
399#[cfg(test)]
400mod tests {
401 use super::*;
402 use mj_core::activity::InFlightToolCall;
403 use serde_json::json;
404 fn message(id: &str, text: &str) -> SessionUpdate {
405 serde_json::from_value(json!({"sessionUpdate":"agent_message_chunk","messageId":id,"content":{"type":"text","text":text}})).unwrap()
406 }
407
408 fn tool(title: &str) -> SessionUpdate {
409 serde_json::from_value(json!({"sessionUpdate":"tool_call","toolCallId":title,"title":title,"status":"in_progress"})).unwrap()
410 }
411
412 #[test]
413 fn diagnostic_logging_does_not_change_evidence_or_generation_and_survives_reset() {
414 let context = TurnContext::default();
415 context.reset("Implement the parser");
416 let generation = context.generation();
417 let before = serde_json::to_value(context.evidence(
418 HarnessKind::Codex,
419 TurnPhase::Running,
420 &Default::default(),
421 0,
422 ))
423 .unwrap();
424 let dir = tempfile::tempdir().unwrap();
425 let log = mj_core::jev::DecisionLog::open(dir.path().into()).unwrap();
426 context.set_decision_log(log.clone());
427 let attempt = log.start("s", "activity", "Who acts?", "Current request");
428 attempt.finish("unchanged", "Kept runtime facts");
429 assert_eq!(context.generation(), generation);
430 assert_eq!(
431 serde_json::to_value(context.evidence(
432 HarnessKind::Codex,
433 TurnPhase::Running,
434 &Default::default(),
435 0
436 ))
437 .unwrap(),
438 before
439 );
440 context.reset("New work");
441 assert!(context.decision_log().is_some());
442 }
443
444 #[test]
445 fn verdict_uses_latest_delivered_user_without_tool_history_but_keeps_live_facts() {
446 use mj_core::relay::RelayObservation;
447 let context = TurnContext::default();
448 let start = |id: &str| RelayObservation::CommandStarted {
449 command_id: id.into(),
450 started_at_ms: 0,
451 };
452 context.observe_relay(&start("old"), Some("OLD REQUEST"));
453 context.observe(&message("old", "OLD ANSWER"));
454 context.observe(&tool("active-build"));
455 context.observe_relay(&start("new"), Some("CURRENT REQUEST"));
456 let noisy: SessionUpdate = serde_json::from_value(json!({
457 "sessionUpdate":"tool_call", "toolCallId":"noisy", "title":"noisy", "status":"completed",
458 "rawInput":{"command":"TOOL_BODY".repeat(20_000)}
459 })).unwrap();
460 context.observe(&noisy);
461 context.observe(&message("new", "CURRENT ANSWER"));
462 let facts = ActivityFacts {
463 background_commands: 2,
464 queued_commands: 3,
465 tools_in_flight: vec![InFlightToolCall {
466 tool_call_id: "active-build".into(),
467 title: Some("active-build".into()),
468 status: agent_client_protocol::schema::v1::ToolCallStatus::InProgress,
469 started_at_ms: 1_000,
470 }],
471 ..Default::default()
472 };
473 let evidence = context.evidence(HarnessKind::Codex, TurnPhase::Running, &facts, 5_000);
474 assert!(evidence.transcript_summary.contains("CURRENT REQUEST"));
475 assert!(evidence.transcript_summary.contains("CURRENT ANSWER"));
476 for excluded in [
477 "OLD REQUEST",
478 "OLD ANSWER",
479 "TOOL_BODY",
480 "<tool",
481 "bytes omitted",
482 ] {
483 assert!(
484 !evidence.transcript_summary.contains(excluded),
485 "{excluded}"
486 );
487 }
488 assert_eq!(evidence.user_prompt_tail, "CURRENT REQUEST");
489 assert_eq!(evidence.assistant_text_tail, "CURRENT ANSWER");
490 assert_eq!(evidence.background_commands, 2);
491 assert_eq!(evidence.queued_commands, 3);
492 assert_eq!(evidence.tools_in_flight.len(), 1);
493 assert!(evidence.tools_in_flight[0].title.contains("active-build"));
494 assert_eq!(evidence.tools_in_flight[0].running_s, 4);
495 let full = context.0.lock().unwrap().summary.render(256 * 1024);
496 assert!(full.contains("OLD REQUEST") && full.contains("TOOL_BODY"));
497 }
498
499 #[test]
500 fn tool_calls_after_the_last_assistant_text_are_named_in_the_evidence() {
501 use mj_core::relay::RelayObservation;
502 let context = TurnContext::default();
503 context.observe_relay(
504 &RelayObservation::CommandStarted {
505 command_id: "audit".into(),
506 started_at_ms: 0,
507 },
508 Some("Audit the cache and hand back."),
509 );
510 context.observe(&message("m1", "I'm checking the update paths now."));
511 let handback: SessionUpdate = serde_json::from_value(json!({
512 "sessionUpdate":"tool_call", "toolCallId":"hb", "title":"mcp__mj-agents__handback",
513 "status":"completed", "rawInput":{"report":"done"}
514 }))
515 .unwrap();
516 context.observe(&handback);
517 let facts = ActivityFacts::default();
518 let evidence = context.evidence(HarnessKind::Codex, TurnPhase::Replied, &facts, 5_000);
519 assert_eq!(evidence.final_tool_calls.len(), 1);
520 assert!(evidence.final_tool_calls[0].name.contains("handback"));
521 assert_eq!(evidence.final_tool_calls[0].status, "completed");
522 context.observe(&message("m2", "Handed back."));
524 let evidence = context.evidence(HarnessKind::Codex, TurnPhase::Replied, &facts, 5_000);
525 assert!(evidence.final_tool_calls.is_empty());
526 }
527
528 #[test]
529 fn evidence_caps_utf8_text_tools_and_durations() {
530 let context = TurnContext::default();
531 context.reset(&"é".repeat(20_000));
532 context.observe(&message("first", &"🦀".repeat(20_000)));
533 let first = context.evidence(
534 HarnessKind::Claude,
535 TurnPhase::Running,
536 &ActivityFacts::default(),
537 0,
538 );
539 assert_eq!(first.user_prompt_tail.len(), USER_PROMPT_BYTES);
540 assert_eq!(first.assistant_text_tail.len(), ASSISTANT_TEXT_BYTES);
541 for n in 0..20 {
542 context.observe(&tool(&format!("{n}{}", "é".repeat(300))));
543 }
544 let facts = ActivityFacts {
545 last_acp_activity_at_ms: Some(1_000),
546 background_commands: 3,
547 queued_commands: 2,
548 tools_in_flight: (0..100)
549 .map(|n| InFlightToolCall {
550 tool_call_id: n.to_string(),
551 title: Some("🦀".repeat(200)),
552 status: agent_client_protocol::schema::v1::ToolCallStatus::InProgress,
553 started_at_ms: 2_000,
554 })
555 .collect(),
556 ..Default::default()
557 };
558 let evidence = context.evidence(HarnessKind::Claude, TurnPhase::Running, &facts, 95_000);
559 assert_eq!(evidence.tools_in_flight.len(), IN_FLIGHT_TOOLS);
560 assert!(
561 evidence
562 .tools_in_flight
563 .iter()
564 .all(|tool| tool.title.len() <= TOOL_TITLE_BYTES && tool.running_s == 93)
565 );
566 assert!(evidence.transcript_summary.len() <= 48 * 1024);
567 assert_eq!(evidence.assistant_text_tail, first.assistant_text_tail);
568 assert_eq!(evidence.silent_for_s, 94);
569 let json = serde_json::to_value(evidence).unwrap();
570 assert_eq!(json["phase"], "running");
571 assert_eq!(json["harness"], "claude");
572 assert_eq!(json["background_commands"], 3);
573 assert_eq!(json["queued_commands"], 2);
574 assert!(serde_json::to_vec(&json).unwrap().len() < 64 * 1024);
575 }
576
577 #[test]
578 fn message_boundaries_and_prompt_reset_do_not_mix_answers() {
579 let context = TurnContext::default();
580 context.reset("First prompt");
581 context.observe(&message("one", "Old answer"));
582 context.observe(&message("two", "New "));
583 context.observe(&message("two", "answer?"));
584 let evidence = context.evidence(
585 HarnessKind::Claude,
586 TurnPhase::Replied,
587 &ActivityFacts::default(),
588 0,
589 );
590 assert_eq!(evidence.assistant_text_tail, "New answer?");
591 let generation = context.generation();
592 context.set_counts(1, 2);
593 assert_ne!(context.generation(), generation);
594 let generation = context.generation();
595 context.set_counts(1, 2);
596 assert_eq!(context.generation(), generation);
597 assert_eq!(context.counts(), (1, 2));
598 context.invalidate();
599 assert_ne!(context.generation(), generation);
600 assert_eq!(context.counts(), (1, 2));
601 let generation = context.generation();
602 context.reset("Second prompt");
603 assert_eq!(context.counts(), (1, 2));
604 assert_ne!(context.generation(), generation);
605 let evidence = context.evidence(
606 HarnessKind::Claude,
607 TurnPhase::Running,
608 &ActivityFacts::default(),
609 0,
610 );
611 assert_eq!(evidence.user_prompt_tail, "Second prompt");
612 assert!(evidence.assistant_text_tail.is_empty());
613 assert!(!evidence.transcript_summary.contains("<user>"));
614 }
615}