cc-transcript 10.2.0__tar.gz → 10.4.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.2.0 → cc_transcript-10.4.0}/PKG-INFO +1 -1
  2. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/activity_probe.py +13 -10
  3. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/mining/signals.py +1 -1
  4. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/tools.py +23 -9
  5. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/pyproject.toml +1 -1
  6. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/src/activity.rs +61 -13
  7. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/src/mining.rs +8 -14
  8. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/src/types.rs +13 -1
  9. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/Cargo.lock +0 -0
  10. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/Cargo.toml +0 -0
  11. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/LICENSE +0 -0
  12. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/README.md +0 -0
  13. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/__init__.py +0 -0
  14. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/__main__.py +0 -0
  15. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/_parser_rs.pyi +0 -0
  16. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/activity.py +0 -0
  17. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/backend.py +0 -0
  18. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/builders.py +0 -0
  19. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/cli.py +0 -0
  20. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/command.py +0 -0
  21. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/context.py +0 -0
  22. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/corrections.py +0 -0
  23. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/corrections_cli.py +0 -0
  24. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/cost.py +0 -0
  25. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/decisions.py +0 -0
  26. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/discovery.py +0 -0
  27. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/disktruth.py +0 -0
  28. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/evidence.py +0 -0
  29. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/extract/__init__.py +0 -0
  30. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/extract/correct.py +0 -0
  31. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/facts.py +0 -0
  32. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/filterspec.py +0 -0
  33. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/ids.py +0 -0
  34. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/judge/__init__.py +0 -0
  35. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/judge/llm.py +0 -0
  36. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/judge/similar.py +0 -0
  37. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/judge/verdicts.py +0 -0
  38. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/mining/__init__.py +0 -0
  39. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/mining/candidates.py +0 -0
  40. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/mining/confidence.py +0 -0
  41. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/mining/engine.py +0 -0
  42. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/mining/filterspec.py +0 -0
  43. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/mining/formats.py +0 -0
  44. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/mining/sourcekind.py +0 -0
  45. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/mining/spec.py +0 -0
  46. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/mining/store.py +0 -0
  47. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/models.py +0 -0
  48. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/notifications.py +0 -0
  49. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/parser.py +0 -0
  50. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/py.typed +0 -0
  51. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/query.py +0 -0
  52. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/render.py +0 -0
  53. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/rust.py +0 -0
  54. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/sentiment/__init__.py +0 -0
  55. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/sentiment/buckets.py +0 -0
  56. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/sentiment/engine.py +0 -0
  57. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/sentiment/lexicon.py +0 -0
  58. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/sentiment/scorespec.py +0 -0
  59. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/cc_transcript/store.py +0 -0
  60. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/Cargo.toml +0 -0
  61. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/build.rs +0 -0
  62. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/data/afinn-en-165.tsv +0 -0
  63. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/data/domain_overrides.tsv +0 -0
  64. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/src/command.rs +0 -0
  65. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/src/event.rs +0 -0
  66. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/src/filter.rs +0 -0
  67. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/src/lexicon.rs +0 -0
  68. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/src/lib.rs +0 -0
  69. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/src/model.rs +0 -0
  70. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/src/parse.rs +0 -0
  71. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/src/protocol.rs +0 -0
  72. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/src/python.rs +0 -0
  73. {cc_transcript-10.2.0 → cc_transcript-10.4.0}/rust/src/score.rs +0 -0
  74. {cc_transcript-10.2.0 → cc_transcript-10.4.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.2.0
3
+ Version: 10.4.0
4
4
  Classifier: Development Status :: 3 - Alpha
5
5
  Classifier: Environment :: Console
6
6
  Classifier: Intended Audience :: Developers
@@ -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)
@@ -132,7 +132,7 @@ def last_edit_index(events: Sequence[TranscriptEvent], index: int, spec: MiningS
132
132
  i
133
133
  for i in range(index - 1, max(index - spec.reentry_lookback, 0) - 1, -1)
134
134
  if isinstance(event := events[i], AssistantEvent)
135
- if any(isinstance(b, ToolUseBlock) and b.name in spec.edit_tools for b in event.blocks)
135
+ if any(isinstance(b, ToolUseBlock) and matches_names(b.name, spec.edit_tools) for b in event.blocks)
136
136
  ),
137
137
  None,
138
138
  )
@@ -32,6 +32,14 @@ TOOL_ALIASES: dict[str, str] = {
32
32
 
33
33
  TOOL_ALIASES_REVERSE: dict[str, str] = {v: k for k, v in TOOL_ALIASES.items()}
34
34
 
35
+ # cc-context routes real file edits through MCP tools whose bare names match no
36
+ # builtin edit gate; alias its write surface to the builtins those gates watch so
37
+ # an edit through the MCP can't slip past a Tool("Edit"/"Write"/"MultiEdit") guard.
38
+ MCP_TOOL_ALIASES: dict[str, str] = {
39
+ "ccx_code_edit": "Edit",
40
+ "ccx_code_replace": "Write",
41
+ }
42
+
35
43
  READ_VERBS = frozenset({"get", "list", "search", "read", "view", "fetch", "query", "describe", "show", "find"})
36
44
 
37
45
 
@@ -482,28 +490,34 @@ def file_path_of(call: ToolCall) -> str | None:
482
490
 
483
491
 
484
492
  def expand_tool_names(spec: str) -> frozenset[str]:
485
- """Expand a pipe-separated tool spec to include both alias spellings."""
486
- return frozenset(
487
- (base := set(spec.split("|")))
488
- | {alias for n in base for alias in (TOOL_ALIASES.get(n), TOOL_ALIASES_REVERSE.get(n)) if alias}
489
- )
493
+ """Expand a pipe-separated tool spec to include alias and MCP bare spellings."""
494
+ base = set(spec.split("|"))
495
+ expanded = base | {alias for n in base for alias in (TOOL_ALIASES.get(n), TOOL_ALIASES_REVERSE.get(n)) if alias}
496
+ return frozenset(expanded | {bare for bare, builtin in MCP_TOOL_ALIASES.items() if builtin in expanded})
490
497
 
491
498
 
492
499
  def matches_names(actual: str, names: Container[str]) -> bool:
493
500
  """Whether ``actual`` is one of ``names``, exactly or as an MCP tool suffix.
494
501
 
495
502
  True when ``actual`` is in ``names``, or when it splits as
496
- ``mcp__<server>__<tool>`` on the first two ``__`` and ``<tool>`` is in
497
- ``names``. ``names`` is taken verbatim no alias closure; pre-expand with
498
- :func:`expand_tool_names` when aliases should match.
503
+ ``mcp__<server>__<tool>`` on the first two ``__`` and ``<tool>`` or its
504
+ :data:`MCP_TOOL_ALIASES` builtin equivalentis in ``names``. That alias
505
+ closes the edit-gate bypass where cc-context's ``ccx_code_edit`` /
506
+ ``ccx_code_replace`` write through the MCP under names no ``Edit``/``Write``
507
+ gate would catch. Harness-rename aliases are not closed over — ``names`` is
508
+ taken verbatim; pre-expand with :func:`expand_tool_names` for those.
499
509
 
500
510
  Example:
501
511
  >>> matches_names("mcp__github__Grep", {"Grep"})
502
512
  True
513
+ >>> matches_names("mcp__cc-context__ccx_code_edit", {"Edit"})
514
+ True
503
515
  >>> matches_names("Execute", {"Bash"})
504
516
  False
505
517
  """
506
- return actual in names or ((mp := mcp_parts(actual)) is not None and mp[1] in names)
518
+ return actual in names or (
519
+ (mp := mcp_parts(actual)) is not None and (mp[1] in names or MCP_TOOL_ALIASES.get(mp[1]) in names)
520
+ )
507
521
 
508
522
 
509
523
  def tool_name_matches(actual: str, spec: str) -> bool:
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "cc-transcript"
3
- version = "10.2.0"
3
+ version = "10.4.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
  }
@@ -61,8 +74,8 @@ pub struct SessionActivity {
61
74
 
62
75
  /// Whether a user entry opens a turn (activity.py ``native_user_classifier``):
63
76
  /// 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.
77
+ /// non-blank text. Compact-summary exclusion matches captain-hook, which
78
+ /// consumes this probe since the 10.2.0 shared-classifier fix.
66
79
  fn opens_turn(user: &UserEntry) -> bool {
67
80
  !(user.meta.is_meta || user.meta.is_sidechain || user.meta.is_compact_summary || user.interrupted())
68
81
  && !user.content.text().trim().is_empty()
@@ -78,18 +91,19 @@ fn current_turn_start(entries: &[Entry]) -> usize {
78
91
  }
79
92
 
80
93
  /// conditions.py ``ephemeral_wait``: a waiting tool, a backgrounded
81
- /// Agent/Task/Bash, or a subagentless Agent/Task.
94
+ /// Agent/Task/Bash, or a subagentless Agent/Task — every name matched alias-
95
+ /// and MCP-prefix-aware (tools.py matches_names).
82
96
  fn ephemeral_wait(tool_use: &ToolUseBlock, waiting_tools: &HashSet<String>) -> Option<PendingKind> {
83
- if waiting_tools.contains(&tool_use.name) {
97
+ if matches_names(&tool_use.name, waiting_tools) {
84
98
  return Some(PendingKind::WaitingTool);
85
99
  }
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,
100
+ if matches_names(&tool_use.name, &BACKGROUND_TOOLS) && tool_use.run_in_background == Some(true) {
101
+ return Some(PendingKind::Background);
102
+ }
103
+ if matches_names(&tool_use.name, &TASK_TOOLS) && tool_use.subagent_type.is_none() {
104
+ return Some(PendingKind::SubagentlessTask);
92
105
  }
106
+ None
93
107
  }
94
108
 
95
109
  /// conditions.py ``pending_async``: an async Agent/Task launch or a live
@@ -217,7 +231,7 @@ pub fn session_activity(entries: &[Entry], opts: &ActivityOpts) -> SessionActivi
217
231
  .or_else(|| pending_async(tool_use, result, &notifications));
218
232
  let unmatched = in_current_turn
219
233
  && result.is_none()
220
- && !opts.human_facing_tools.contains(&tool_use.name);
234
+ && !matches_names(&tool_use.name, &opts.human_facing_tools);
221
235
  is_waiting |= waiting_kind.is_some();
222
236
  mid_tool |= unmatched;
223
237
  let Some(kind) = waiting_kind.or(unmatched.then_some(PendingKind::MidTool)) else {
@@ -545,6 +559,17 @@ mod tests {
545
559
  assert!(activity.pending.is_empty());
546
560
  }
547
561
 
562
+ #[test]
563
+ fn background_execute_alias_in_current_turn_is_waiting() {
564
+ let entries = vec![
565
+ user("build it"),
566
+ tool_use("Execute", "e1", r#"{"command":"make","run_in_background":true}"#),
567
+ ];
568
+ let activity = activity(&entries);
569
+ assert!(activity.is_waiting);
570
+ assert_eq!(activity.pending[0].kind, PendingKind::Background);
571
+ }
572
+
548
573
  #[test]
549
574
  fn subagentless_agent_is_waiting() {
550
575
  let entries = vec![
@@ -601,6 +626,17 @@ mod tests {
601
626
  assert_eq!(activity.pending[0].kind, PendingKind::WaitingTool);
602
627
  }
603
628
 
629
+ #[test]
630
+ fn mcp_prefixed_waiting_tool_is_waiting() {
631
+ let entries = vec![
632
+ user("ping the pool"),
633
+ tool_use("mcp__pool__SendMessage", "s1", r#"{"text":"hi"}"#),
634
+ ];
635
+ let activity = activity(&entries);
636
+ assert!(activity.is_waiting);
637
+ assert_eq!(activity.pending[0].kind, PendingKind::WaitingTool);
638
+ }
639
+
604
640
  #[test]
605
641
  fn pending_ask_user_question_is_quiet() {
606
642
  let entries = vec![
@@ -613,6 +649,18 @@ mod tests {
613
649
  assert!(activity.pending.is_empty());
614
650
  }
615
651
 
652
+ #[test]
653
+ fn mcp_prefixed_human_facing_tool_is_not_mid_tool() {
654
+ let entries = vec![
655
+ user("choose"),
656
+ tool_use("mcp__someserver__AskUserQuestion", "q1", r#"{"questions":[]}"#),
657
+ ];
658
+ let activity = activity(&entries);
659
+ assert!(!activity.is_waiting);
660
+ assert!(!activity.mid_tool);
661
+ assert!(activity.pending.is_empty());
662
+ }
663
+
616
664
  #[test]
617
665
  fn unmatched_bash_in_current_turn_is_mid_tool() {
618
666
  let entries = vec![user("list"), tool_use("Bash", "b1", r#"{"command":"ls"}"#)];
@@ -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).
@@ -473,23 +475,11 @@ impl Events {
473
475
  let lo = index.saturating_sub(spec.reentry_lookback);
474
476
  (lo..index)
475
477
  .rev()
476
- .find(|&i| self.entries[i].tool_uses().any(|tu| spec.edit_tools.contains(&tu.name)))
478
+ .find(|&i| self.entries[i].tool_uses().any(|tu| matches_names(&tu.name, &spec.edit_tools)))
477
479
  .map(|i| i as i64)
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
@@ -1316,6 +1306,10 @@ mod tests {
1316
1306
  assert!(matches_names("mcp__conductor__ExitPlanMode", &names));
1317
1307
  assert!(!matches_names("mcp__ExitPlanMode", &names));
1318
1308
  assert!(!matches_names("AskUserQuestion", &names));
1309
+
1310
+ let edit_names: HashSet<String> = ["Edit".to_string(), "ccx_code_edit".to_string()].into();
1311
+ assert!(matches_names("mcp__cc-context__ccx_code_edit", &edit_names));
1312
+ assert!(!matches_names("mcp__cc-context__ccx_code_read", &edit_names));
1319
1313
  }
1320
1314
 
1321
1315
  #[test]
@@ -1,4 +1,4 @@
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;
@@ -225,6 +225,18 @@ pub fn tool_use_index(entries: &[Entry]) -> HashMap<&str, &ToolUseBlock> {
225
225
  .collect()
226
226
  }
227
227
 
228
+ /// matches_names (tools.py matches_names): exact membership, or the ``<tool>``
229
+ /// segment of an ``mcp__<server>__<tool>`` name split on the first two ``__``.
230
+ /// Alias closure happens Python-side (tools.py expand_tool_names); spec JSON
231
+ /// arrives pre-expanded and the alias table never crosses the language boundary.
232
+ pub(crate) fn matches_names(actual: &str, names: &HashSet<String>) -> bool {
233
+ names.contains(actual)
234
+ || actual
235
+ .strip_prefix("mcp__")
236
+ .and_then(|rest| rest.split_once("__"))
237
+ .is_some_and(|(_, tool)| names.contains(tool))
238
+ }
239
+
228
240
  /// The space-joined text of the ``Text`` blocks — the ``text`` field of the
229
241
  /// Python ``AssistantEvent``/``PrintMessage``.
230
242
  pub fn joined_text(blocks: &[ContentBlock]) -> String {
File without changes
File without changes