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