cc-transcript 10.3.0__tar.gz → 10.5.0__tar.gz

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (74) hide show
  1. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/PKG-INFO +1 -1
  2. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/activity.py +7 -2
  3. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/activity_probe.py +13 -10
  4. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/filterspec.py +5 -0
  5. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/models.py +4 -0
  6. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/parser.py +2 -1
  7. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/pyproject.toml +1 -1
  8. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/activity.rs +84 -15
  9. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/event.rs +3 -1
  10. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/mining.rs +3 -13
  11. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/protocol.rs +37 -0
  12. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/types.rs +18 -2
  13. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/Cargo.lock +0 -0
  14. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/Cargo.toml +0 -0
  15. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/LICENSE +0 -0
  16. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/README.md +0 -0
  17. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/__init__.py +0 -0
  18. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/__main__.py +0 -0
  19. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/_parser_rs.pyi +0 -0
  20. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/backend.py +0 -0
  21. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/builders.py +0 -0
  22. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/cli.py +0 -0
  23. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/command.py +0 -0
  24. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/context.py +0 -0
  25. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/corrections.py +0 -0
  26. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/corrections_cli.py +0 -0
  27. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/cost.py +0 -0
  28. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/decisions.py +0 -0
  29. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/discovery.py +0 -0
  30. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/disktruth.py +0 -0
  31. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/evidence.py +0 -0
  32. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/extract/__init__.py +0 -0
  33. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/extract/correct.py +0 -0
  34. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/facts.py +0 -0
  35. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/ids.py +0 -0
  36. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/judge/__init__.py +0 -0
  37. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/judge/llm.py +0 -0
  38. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/judge/similar.py +0 -0
  39. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/judge/verdicts.py +0 -0
  40. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/__init__.py +0 -0
  41. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/candidates.py +0 -0
  42. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/confidence.py +0 -0
  43. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/engine.py +0 -0
  44. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/filterspec.py +0 -0
  45. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/formats.py +0 -0
  46. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/signals.py +0 -0
  47. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/sourcekind.py +0 -0
  48. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/spec.py +0 -0
  49. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/mining/store.py +0 -0
  50. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/notifications.py +0 -0
  51. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/py.typed +0 -0
  52. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/query.py +0 -0
  53. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/render.py +0 -0
  54. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/rust.py +0 -0
  55. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/sentiment/__init__.py +0 -0
  56. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/sentiment/buckets.py +0 -0
  57. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/sentiment/engine.py +0 -0
  58. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/sentiment/lexicon.py +0 -0
  59. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/sentiment/scorespec.py +0 -0
  60. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/store.py +0 -0
  61. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/cc_transcript/tools.py +0 -0
  62. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/Cargo.toml +0 -0
  63. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/build.rs +0 -0
  64. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/data/afinn-en-165.tsv +0 -0
  65. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/data/domain_overrides.tsv +0 -0
  66. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/command.rs +0 -0
  67. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/filter.rs +0 -0
  68. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/lexicon.rs +0 -0
  69. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/lib.rs +0 -0
  70. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/model.rs +0 -0
  71. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/parse.rs +0 -0
  72. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/python.rs +0 -0
  73. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/score.rs +0 -0
  74. {cc_transcript-10.3.0 → cc_transcript-10.5.0}/rust/src/value.rs +0 -0
@@ -1,6 +1,6 @@
1
1
  Metadata-Version: 2.4
2
2
  Name: cc-transcript
3
- Version: 10.3.0
3
+ Version: 10.5.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -40,10 +40,15 @@ def native_user_classifier(event: UserEvent) -> bool:
40
40
  """Whether a user event is a real prompt under native Claude Code semantics.
41
41
 
42
42
  A prompt is non-meta, non-sidechain, not a compact summary, not an
43
- interruption marker, and not tool-result-only — it must carry real text.
43
+ interruption marker, not an agent-injected relay banner, and not
44
+ tool-result-only — it must carry real text.
44
45
  """
45
46
  return not (
46
- event.meta.is_meta or event.meta.is_sidechain or event.meta.is_compact_summary or event.interrupted
47
+ event.meta.is_meta
48
+ or event.meta.is_sidechain
49
+ or event.meta.is_compact_summary
50
+ or event.interrupted
51
+ or event.is_agent_injected
47
52
  ) and bool(event.text.strip())
48
53
 
49
54
 
@@ -10,8 +10,8 @@ stays identical to. The verdict spans undelivered task notifications (the
10
10
  turn, and pending async launches (async Agent/Task, live Workflow) session-wide — a
11
11
  launch counts as completed only once its notification was delivered or drained, never
12
12
  when merely enqueued — and flags unanswered non-human-facing tool calls in the current
13
- turn as mid-tool. Compact-summary user lines do not open turns, deliberately leading
14
- captain-hook, which still lets compaction close the turn.
13
+ turn as mid-tool. Compact-summary user lines do not open turns, matching captain-hook,
14
+ which consumes this probe since the 10.2.0 shared-classifier fix.
15
15
  """
16
16
 
17
17
  from __future__ import annotations
@@ -30,6 +30,7 @@ from cc_transcript.models import (
30
30
  )
31
31
  from cc_transcript.notifications import Notifications
32
32
  from cc_transcript.parser import parse_events_from_bytes
33
+ from cc_transcript.tools import expand_tool_names, matches_names
33
34
 
34
35
  if TYPE_CHECKING:
35
36
  from collections.abc import Sequence
@@ -39,7 +40,10 @@ if TYPE_CHECKING:
39
40
  from cc_transcript.models import TranscriptEvent
40
41
 
41
42
  DEFAULT_WAITING_TOOLS = frozenset({"Monitor", "ScheduleWakeup", "SendMessage", "TeamCreate"})
42
- DEFAULT_HUMAN_FACING_TOOLS = frozenset({"AskUserQuestion", "ExitPlanMode"})
43
+ DEFAULT_HUMAN_FACING_TOOLS = expand_tool_names("AskUserQuestion|ExitPlanMode")
44
+
45
+ BACKGROUND_TOOLS = expand_tool_names("Agent|Task|Bash")
46
+ TASK_TOOLS = expand_tool_names("Agent|Task")
43
47
 
44
48
  PendingKind = Literal[
45
49
  "waiting_tool",
@@ -85,13 +89,12 @@ class SessionActivityProbe:
85
89
 
86
90
 
87
91
  def ephemeral_wait(block: ToolUseBlock, waiting_tools: frozenset[str]) -> PendingKind | None:
88
- if block.name in waiting_tools:
92
+ if matches_names(block.name, waiting_tools):
89
93
  return "waiting_tool"
90
- match block.name:
91
- case "Agent" | "Task" | "Bash" if block.input.get("run_in_background") is True:
92
- return "background"
93
- case "Agent" | "Task" if not isinstance(block.input.get("subagent_type"), str):
94
- return "subagentless_task"
94
+ if matches_names(block.name, BACKGROUND_TOOLS) and block.input.get("run_in_background") is True:
95
+ return "background"
96
+ if matches_names(block.name, TASK_TOOLS) and not isinstance(block.input.get("subagent_type"), str):
97
+ return "subagentless_task"
95
98
  return None
96
99
 
97
100
 
@@ -151,7 +154,7 @@ def probe_events(
151
154
  waiting_kind = (ephemeral_wait(block, waiting_tools) if in_current_turn else None) or pending_async(
152
155
  block, result, notifications
153
156
  )
154
- unmatched = in_current_turn and result is None and block.name not in human_facing_tools
157
+ unmatched = in_current_turn and result is None and not matches_names(block.name, human_facing_tools)
155
158
  is_waiting = is_waiting or waiting_kind is not None
156
159
  mid_tool = mid_tool or unmatched
157
160
  kind = waiting_kind or ("mid_tool" if unmatched else None)
@@ -419,6 +419,10 @@ def is_bare_interrupt_marker(text: str) -> bool:
419
419
  return (marker := interrupt_marker(text)) is not None and not text.strip()[len(marker.strip()) :].strip()
420
420
 
421
421
 
422
+ def is_agent_injection(text: str) -> bool:
423
+ return AGENT_INJECTION_RE.search(text) is not None
424
+
425
+
422
426
  def normalize_bare(text: str, strip_trailing: str = TRAILING_PUNCT) -> str:
423
427
  return text.strip().rstrip(strip_trailing).strip().lower()
424
428
 
@@ -551,5 +555,6 @@ def predicate_to_dict(predicate: Predicate) -> dict[str, Any]:
551
555
 
552
556
 
553
557
  STRUCTURAL_NOISE_RE = compile_groups(STRUCTURAL_NOISE_GROUPS, True)
558
+ AGENT_INJECTION_RE = compile_groups(AGENT_INJECTION_GROUPS, True)
554
559
  INTERRUPT_MARKER_RE = compile_groups(INTERRUPT_MARKER_GROUPS, True)
555
560
  JUNK_USER_MESSAGE_RE = compile_groups(SENTIMENT_JUNK_GROUPS, True)
@@ -160,12 +160,16 @@ class UserEvent:
160
160
  text: The joined text of the turn.
161
161
  blocks: The parsed content blocks, including tool results.
162
162
  interrupted: Whether the turn is a user interruption.
163
+ is_agent_injected: Whether the turn is an agent-injected relay banner —
164
+ a teammate-message digest, scheduled-task banner, or foreign-agent
165
+ header — rather than an authored prompt.
163
166
  """
164
167
 
165
168
  meta: EntryMeta
166
169
  text: str
167
170
  blocks: tuple[ContentBlock, ...]
168
171
  interrupted: bool
172
+ is_agent_injected: bool = False
169
173
 
170
174
 
171
175
  @dataclass(frozen=True, slots=True)
@@ -11,7 +11,7 @@ import anyio.to_thread
11
11
  import orjson
12
12
 
13
13
  from cc_transcript.backend import Backend, ParsedTranscript
14
- from cc_transcript.filterspec import apply_spec, interrupt_marker
14
+ from cc_transcript.filterspec import apply_spec, interrupt_marker, is_agent_injection
15
15
  from cc_transcript.models import (
16
16
  AssistantEvent,
17
17
  CacheCreation,
@@ -158,6 +158,7 @@ def parse_event(data: Mapping[str, Any]) -> TranscriptEvent | None:
158
158
  text=text,
159
159
  blocks=blocks,
160
160
  interrupted=interrupt_marker(text) is not None,
161
+ is_agent_injected=is_agent_injection(text),
161
162
  )
162
163
  case "assistant":
163
164
  text, blocks = parse_assistant_blocks(data["message"]["content"])
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cc-transcript"
3
- version = "10.3.0"
3
+ version = "10.5.0"
4
4
  description = "Grep every Claude Code session you've ever run."
5
5
  readme = "README.md"
6
6
  license = "PolyForm-Noncommercial-1.0.0"
@@ -1,10 +1,19 @@
1
1
  use std::collections::{HashMap, HashSet};
2
2
 
3
- use crate::types::{Entry, ToolResultBlock, ToolUseBlock, UserEntry};
3
+ use once_cell::sync::Lazy;
4
+
5
+ use crate::types::{matches_names, Entry, ToolResultBlock, ToolUseBlock, UserEntry};
4
6
  use crate::value::{field, field_str};
5
7
 
6
8
  const NOTIFICATION_MARKER: &str = "<task-notification>";
7
9
 
10
+ // tools.py expand_tool_names("Agent|Task|Bash") / ("Agent|Task"), pre-expanded
11
+ // here because the alias table never crosses the language boundary.
12
+ static BACKGROUND_TOOLS: Lazy<HashSet<String>> =
13
+ Lazy::new(|| HashSet::from(["Agent", "Task", "Bash", "Execute"].map(String::from)));
14
+ static TASK_TOOLS: Lazy<HashSet<String>> =
15
+ Lazy::new(|| HashSet::from(["Agent", "Task"].map(String::from)));
16
+
8
17
  pub struct ActivityOpts {
9
18
  pub waiting_tools: HashSet<String>,
10
19
  pub human_facing_tools: HashSet<String>,
@@ -16,7 +25,11 @@ impl Default for ActivityOpts {
16
25
  waiting_tools: HashSet::from(
17
26
  ["Monitor", "ScheduleWakeup", "SendMessage", "TeamCreate"].map(String::from),
18
27
  ),
19
- human_facing_tools: HashSet::from(["AskUserQuestion", "ExitPlanMode"].map(String::from)),
28
+ // tools.py expand_tool_names("AskUserQuestion|ExitPlanMode"), pre-expanded
29
+ // here because the alias table never crosses the language boundary.
30
+ human_facing_tools: HashSet::from(
31
+ ["AskUserQuestion", "ExitPlanMode", "ExitSpecMode"].map(String::from),
32
+ ),
20
33
  }
21
34
  }
22
35
  }
@@ -60,11 +73,16 @@ pub struct SessionActivity {
60
73
  }
61
74
 
62
75
  /// Whether a user entry opens a turn (activity.py ``native_user_classifier``):
63
- /// a real prompt — non-meta, non-sidechain, not an interruption marker, with
64
- /// non-blank text. Compact-summary exclusion deliberately leads captain-hook,
65
- /// which still lets compaction close the turn; convergence is a follow-up there.
76
+ /// a real prompt — non-meta, non-sidechain, not an interruption marker, not an
77
+ /// agent-injected relay banner, with non-blank text. Compact-summary exclusion
78
+ /// matches captain-hook, which consumes this probe since the 10.2.0
79
+ /// shared-classifier fix.
66
80
  fn opens_turn(user: &UserEntry) -> bool {
67
- !(user.meta.is_meta || user.meta.is_sidechain || user.meta.is_compact_summary || user.interrupted())
81
+ !(user.meta.is_meta
82
+ || user.meta.is_sidechain
83
+ || user.meta.is_compact_summary
84
+ || user.interrupted()
85
+ || user.is_agent_injected())
68
86
  && !user.content.text().trim().is_empty()
69
87
  }
70
88
 
@@ -78,18 +96,19 @@ fn current_turn_start(entries: &[Entry]) -> usize {
78
96
  }
79
97
 
80
98
  /// conditions.py ``ephemeral_wait``: a waiting tool, a backgrounded
81
- /// Agent/Task/Bash, or a subagentless Agent/Task.
99
+ /// Agent/Task/Bash, or a subagentless Agent/Task — every name matched alias-
100
+ /// and MCP-prefix-aware (tools.py matches_names).
82
101
  fn ephemeral_wait(tool_use: &ToolUseBlock, waiting_tools: &HashSet<String>) -> Option<PendingKind> {
83
- if waiting_tools.contains(&tool_use.name) {
102
+ if matches_names(&tool_use.name, waiting_tools) {
84
103
  return Some(PendingKind::WaitingTool);
85
104
  }
86
- match tool_use.name.as_str() {
87
- "Agent" | "Task" | "Bash" if tool_use.run_in_background == Some(true) => {
88
- Some(PendingKind::Background)
89
- }
90
- "Agent" | "Task" if tool_use.subagent_type.is_none() => Some(PendingKind::SubagentlessTask),
91
- _ => None,
105
+ if matches_names(&tool_use.name, &BACKGROUND_TOOLS) && tool_use.run_in_background == Some(true) {
106
+ return Some(PendingKind::Background);
92
107
  }
108
+ if matches_names(&tool_use.name, &TASK_TOOLS) && tool_use.subagent_type.is_none() {
109
+ return Some(PendingKind::SubagentlessTask);
110
+ }
111
+ None
93
112
  }
94
113
 
95
114
  /// conditions.py ``pending_async``: an async Agent/Task launch or a live
@@ -217,7 +236,7 @@ pub fn session_activity(entries: &[Entry], opts: &ActivityOpts) -> SessionActivi
217
236
  .or_else(|| pending_async(tool_use, result, &notifications));
218
237
  let unmatched = in_current_turn
219
238
  && result.is_none()
220
- && !opts.human_facing_tools.contains(&tool_use.name);
239
+ && !matches_names(&tool_use.name, &opts.human_facing_tools);
221
240
  is_waiting |= waiting_kind.is_some();
222
241
  mid_tool |= unmatched;
223
242
  let Some(kind) = waiting_kind.or(unmatched.then_some(PendingKind::MidTool)) else {
@@ -496,6 +515,22 @@ mod tests {
496
515
  assert_eq!(activity.pending[0].kind, PendingKind::Background);
497
516
  }
498
517
 
518
+ #[test]
519
+ fn agent_injected_banner_does_not_open_turn() {
520
+ let entries = vec![
521
+ user("build it"),
522
+ tool_use("Bash", "b1", r#"{"command":"make","run_in_background":true}"#),
523
+ tool_result("b1"),
524
+ user("<teammate-message from='mate'>ping</teammate-message>"),
525
+ ];
526
+ let activity = activity(&entries);
527
+ assert!(
528
+ activity.is_waiting,
529
+ "an agent-injected relay banner must not open a turn, so the background Bash stays current"
530
+ );
531
+ assert_eq!(activity.pending[0].kind, PendingKind::Background);
532
+ }
533
+
499
534
  #[test]
500
535
  fn unrelated_completion_marker_keeps_waiting() {
501
536
  let entries = vec![
@@ -545,6 +580,17 @@ mod tests {
545
580
  assert!(activity.pending.is_empty());
546
581
  }
547
582
 
583
+ #[test]
584
+ fn background_execute_alias_in_current_turn_is_waiting() {
585
+ let entries = vec![
586
+ user("build it"),
587
+ tool_use("Execute", "e1", r#"{"command":"make","run_in_background":true}"#),
588
+ ];
589
+ let activity = activity(&entries);
590
+ assert!(activity.is_waiting);
591
+ assert_eq!(activity.pending[0].kind, PendingKind::Background);
592
+ }
593
+
548
594
  #[test]
549
595
  fn subagentless_agent_is_waiting() {
550
596
  let entries = vec![
@@ -601,6 +647,17 @@ mod tests {
601
647
  assert_eq!(activity.pending[0].kind, PendingKind::WaitingTool);
602
648
  }
603
649
 
650
+ #[test]
651
+ fn mcp_prefixed_waiting_tool_is_waiting() {
652
+ let entries = vec![
653
+ user("ping the pool"),
654
+ tool_use("mcp__pool__SendMessage", "s1", r#"{"text":"hi"}"#),
655
+ ];
656
+ let activity = activity(&entries);
657
+ assert!(activity.is_waiting);
658
+ assert_eq!(activity.pending[0].kind, PendingKind::WaitingTool);
659
+ }
660
+
604
661
  #[test]
605
662
  fn pending_ask_user_question_is_quiet() {
606
663
  let entries = vec![
@@ -613,6 +670,18 @@ mod tests {
613
670
  assert!(activity.pending.is_empty());
614
671
  }
615
672
 
673
+ #[test]
674
+ fn mcp_prefixed_human_facing_tool_is_not_mid_tool() {
675
+ let entries = vec![
676
+ user("choose"),
677
+ tool_use("mcp__someserver__AskUserQuestion", "q1", r#"{"questions":[]}"#),
678
+ ];
679
+ let activity = activity(&entries);
680
+ assert!(!activity.is_waiting);
681
+ assert!(!activity.mid_tool);
682
+ assert!(activity.pending.is_empty());
683
+ }
684
+
616
685
  #[test]
617
686
  fn unmatched_bash_in_current_turn_is_mid_tool() {
618
687
  let entries = vec![user("list"), tool_use("Bash", "b1", r#"{"command":"ls"}"#)];
@@ -12,7 +12,7 @@ use crate::model::{
12
12
  USER_EVENT_CLS,
13
13
  };
14
14
  use crate::parse::ParseError;
15
- use crate::protocol::interrupt_marker;
15
+ use crate::protocol::{interrupt_marker, is_agent_injection};
16
16
  use crate::types::{
17
17
  joined_text, ContentBlock, Entry, EntryMeta, InitInfo, ModelUsage, PrintBody, PrintMessage,
18
18
  PrintResult, Usage, UserContent,
@@ -155,11 +155,13 @@ pub fn build_event<'py>(py: Python<'py>, entry: &Entry) -> PyResult<Bound<'py, P
155
155
  Entry::User(user) => {
156
156
  let text = user.content.text();
157
157
  let interrupted = interrupt_marker(&text).is_some();
158
+ let is_agent_injected = is_agent_injection(&text);
158
159
  models_type(py, &USER_EVENT_CLS, "UserEvent")?.call1((
159
160
  build_meta(py, &user.meta)?,
160
161
  text,
161
162
  build_user_blocks(py, &user.content)?,
162
163
  interrupted,
164
+ is_agent_injected,
163
165
  ))
164
166
  }
165
167
  Entry::Assistant(assistant) => {
@@ -13,7 +13,9 @@ use crate::protocol::{
13
13
  embedded_user_text, interrupt_marker, is_bare_interrupt_marker, ANSWERED_PREFIX, ANSWERED_TRAILER,
14
14
  DENIAL_PREFIX, INTERRUPT_MARKER_RE,
15
15
  };
16
- use crate::types::{tool_use_index, Entry, EntryMeta, Question, ToolResultBlock, ToolUseBlock, UserEntry};
16
+ use crate::types::{
17
+ matches_names, tool_use_index, Entry, EntryMeta, Question, ToolResultBlock, ToolUseBlock, UserEntry,
18
+ };
17
19
  use crate::value::{field, field_bool, field_str};
18
20
 
19
21
  // Source kinds (mining/sourcekind.py).
@@ -478,18 +480,6 @@ impl Events {
478
480
  }
479
481
  }
480
482
 
481
- /// matches_names (tools.py matches_names): exact membership, or the ``<tool>``
482
- /// segment of an ``mcp__<server>__<tool>`` name split on the first two ``__``.
483
- /// Alias closure happens Python-side (tools.py expand_tool_names); the spec JSON
484
- /// arrives pre-expanded and the alias table never crosses the language boundary.
485
- fn matches_names(actual: &str, names: &HashSet<String>) -> bool {
486
- names.contains(actual)
487
- || actual
488
- .strip_prefix("mcp__")
489
- .and_then(|rest| rest.split_once("__"))
490
- .is_some_and(|(_, tool)| names.contains(tool))
491
- }
492
-
493
483
  // ── text-shape helpers (mining/signals.py) ───────────────────────────────────
494
484
 
495
485
  /// marker_in (mining/signals.py marker_in): whether any tool-result block's
@@ -12,6 +12,12 @@ pub const ANSWERED_PREFIX: &str = "Your questions have been answered: ";
12
12
  pub const ANSWERED_TRAILER: &str = ". You can now continue with these answers in mind.";
13
13
  const INTERRUPT_MARKER_PATTERN: &str = r"^\s*\[Request interrupted by user";
14
14
 
15
+ // Agent-injected relay banners (filterspec.py AGENT_INJECTION_GROUPS): teammate-message
16
+ // digests, scheduled-task banners, and foreign-agent headers. The alternation mirrors
17
+ // compile_groups(AGENT_INJECTION_GROUPS): `(?:p1)|(?:p2)|(?:p3)`.
18
+ const AGENT_INJECTION_PATTERN: &str =
19
+ r"(?:<(?:teammate-message|scheduled-task)\b)|(?:^# Augment Agent\b)|(?:^\s*\[Role Reminder\b)";
20
+
15
21
  /// The one interrupt-marker regex (filterspec.py INTERRUPT_MARKER_RE): the pattern
16
22
  /// compiled case-insensitively, anchored at the start of the haystack with leading
17
23
  /// whitespace tolerated. Shared with mining's structural fallback so both uses
@@ -20,6 +26,19 @@ pub static INTERRUPT_MARKER_RE: Lazy<Regex> = Lazy::new(|| {
20
26
  Regex::new(&format!("(?i){INTERRUPT_MARKER_PATTERN}")).expect("interrupt regex")
21
27
  });
22
28
 
29
+ /// The agent-injection regex (filterspec.py AGENT_INJECTION_RE): the group
30
+ /// alternation compiled case-insensitively, matched with search semantics
31
+ /// (filterspec.py is_agent_injection uses .search()).
32
+ pub static AGENT_INJECTION_RE: Lazy<Regex> = Lazy::new(|| {
33
+ Regex::new(&format!("(?i){AGENT_INJECTION_PATTERN}")).expect("agent injection regex")
34
+ });
35
+
36
+ /// is_agent_injection (filterspec.py is_agent_injection): whether ``text`` is an
37
+ /// agent-injected relay banner rather than an authored prompt.
38
+ pub fn is_agent_injection(text: &str) -> bool {
39
+ AGENT_INJECTION_RE.is_match(text)
40
+ }
41
+
23
42
  /// embedded_user_text (filterspec.py embedded_user_text): the verbatim instruction
24
43
  /// wrapped between the USER_SAID markers, or None.
25
44
  pub fn embedded_user_text(content: &str) -> Option<String> {
@@ -91,4 +110,22 @@ mod tests {
91
110
  assert!(is_bare_interrupt_marker("[Request interrupted by user]"));
92
111
  assert!(!is_bare_interrupt_marker("[Request interrupted by user] no do it differently"));
93
112
  }
113
+
114
+ #[test]
115
+ fn agent_injection_matches_relay_banners() {
116
+ assert!(is_agent_injection("<teammate-message from='r'>rebase</teammate-message>"));
117
+ assert!(is_agent_injection("<scheduled-task id='7'>run the suite</scheduled-task>"));
118
+ assert!(is_agent_injection("[Role Reminder: You are a Coordinator."));
119
+ assert!(is_agent_injection("# Augment Agent\nyou have these tools"));
120
+ // search() is unanchored: a mid-text teammate tag still matches.
121
+ assert!(is_agent_injection("as noted in the <teammate-message> above"));
122
+ }
123
+
124
+ #[test]
125
+ fn agent_injection_rejects_prose_and_head_anchored_mid_text() {
126
+ assert!(!is_agent_injection("remind me what the teammate coordinator does"));
127
+ // the head-anchored role-reminder group does not match mid-text.
128
+ assert!(!is_agent_injection("we discussed the [Role Reminder] banner mid-sentence"));
129
+ assert!(!is_agent_injection(""));
130
+ }
94
131
  }
@@ -1,9 +1,9 @@
1
- use std::collections::HashMap;
1
+ use std::collections::{HashMap, HashSet};
2
2
 
3
3
  use chrono::{DateTime, FixedOffset};
4
4
  use sonic_rs::Value;
5
5
 
6
- use crate::protocol::interrupt_marker;
6
+ use crate::protocol::{interrupt_marker, is_agent_injection};
7
7
 
8
8
  /// Envelope metadata shared by the conversational entry kinds (user, assistant,
9
9
  /// system). Mode and Other entries carry no envelope on disk.
@@ -116,6 +116,10 @@ impl UserEntry {
116
116
  pub fn interrupted(&self) -> bool {
117
117
  interrupt_marker(&self.content.text()).is_some()
118
118
  }
119
+
120
+ pub fn is_agent_injected(&self) -> bool {
121
+ is_agent_injection(&self.content.text())
122
+ }
119
123
  }
120
124
 
121
125
  #[derive(Debug)]
@@ -225,6 +229,18 @@ pub fn tool_use_index(entries: &[Entry]) -> HashMap<&str, &ToolUseBlock> {
225
229
  .collect()
226
230
  }
227
231
 
232
+ /// matches_names (tools.py matches_names): exact membership, or the ``<tool>``
233
+ /// segment of an ``mcp__<server>__<tool>`` name split on the first two ``__``.
234
+ /// Alias closure happens Python-side (tools.py expand_tool_names); spec JSON
235
+ /// arrives pre-expanded and the alias table never crosses the language boundary.
236
+ pub(crate) fn matches_names(actual: &str, names: &HashSet<String>) -> bool {
237
+ names.contains(actual)
238
+ || actual
239
+ .strip_prefix("mcp__")
240
+ .and_then(|rest| rest.split_once("__"))
241
+ .is_some_and(|(_, tool)| names.contains(tool))
242
+ }
243
+
228
244
  /// The space-joined text of the ``Text`` blocks — the ``text`` field of the
229
245
  /// Python ``AssistantEvent``/``PrintMessage``.
230
246
  pub fn joined_text(blocks: &[ContentBlock]) -> String {
File without changes
File without changes