cc-transcript 10.0.0__tar.gz → 10.2.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.
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/Cargo.lock +3 -2
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/PKG-INFO +1 -1
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/activity.py +5 -3
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/activity_probe.py +20 -23
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/pyproject.toml +1 -1
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/Cargo.toml +4 -2
- cc_transcript-10.2.0/rust/build.rs +20 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/activity.rs +260 -28
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/mining.rs +2 -8
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/parse.rs +16 -3
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/Cargo.toml +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/LICENSE +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/README.md +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/__init__.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/__main__.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/_parser_rs.pyi +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/backend.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/builders.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/cli.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/command.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/context.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/corrections.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/corrections_cli.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/cost.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/decisions.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/discovery.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/disktruth.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/evidence.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/extract/__init__.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/extract/correct.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/facts.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/filterspec.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/ids.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/judge/__init__.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/judge/llm.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/judge/similar.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/judge/verdicts.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/__init__.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/candidates.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/confidence.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/engine.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/filterspec.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/formats.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/signals.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/sourcekind.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/spec.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/mining/store.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/models.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/notifications.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/parser.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/py.typed +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/query.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/render.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/rust.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/sentiment/__init__.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/sentiment/buckets.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/sentiment/engine.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/sentiment/lexicon.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/sentiment/scorespec.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/store.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/cc_transcript/tools.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/data/afinn-en-165.tsv +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/data/domain_overrides.tsv +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/command.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/event.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/filter.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/lexicon.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/lib.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/model.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/protocol.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/python.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/score.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/types.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.2.0}/rust/src/value.rs +0 -0
|
@@ -72,7 +72,7 @@ dependencies = [
|
|
|
72
72
|
|
|
73
73
|
[[package]]
|
|
74
74
|
name = "cc_transcript_parser"
|
|
75
|
-
version = "0.
|
|
75
|
+
version = "0.9.0"
|
|
76
76
|
dependencies = [
|
|
77
77
|
"chrono",
|
|
78
78
|
"crossbeam-channel",
|
|
@@ -81,6 +81,7 @@ dependencies = [
|
|
|
81
81
|
"num_cpus",
|
|
82
82
|
"once_cell",
|
|
83
83
|
"pyo3",
|
|
84
|
+
"pyo3-build-config",
|
|
84
85
|
"rayon",
|
|
85
86
|
"regex",
|
|
86
87
|
"sonic-rs",
|
|
@@ -91,7 +92,7 @@ dependencies = [
|
|
|
91
92
|
|
|
92
93
|
[[package]]
|
|
93
94
|
name = "cc_transcript_swift"
|
|
94
|
-
version = "0.
|
|
95
|
+
version = "0.9.0"
|
|
95
96
|
dependencies = [
|
|
96
97
|
"cc_transcript_parser",
|
|
97
98
|
"swift-bridge",
|
|
@@ -39,10 +39,12 @@ UserClassifier = Callable[["UserEvent"], bool]
|
|
|
39
39
|
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
|
-
A prompt is non-meta, non-sidechain, not
|
|
43
|
-
tool-result-only — it must carry real text.
|
|
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.
|
|
44
44
|
"""
|
|
45
|
-
return not (
|
|
45
|
+
return not (
|
|
46
|
+
event.meta.is_meta or event.meta.is_sidechain or event.meta.is_compact_summary or event.interrupted
|
|
47
|
+
) and bool(event.text.strip())
|
|
46
48
|
|
|
47
49
|
|
|
48
50
|
@dataclass(frozen=True, slots=True)
|
|
@@ -4,11 +4,14 @@
|
|
|
4
4
|
to the Python reference. :func:`session_activity_probe` is the public dual-backend entry:
|
|
5
5
|
it reads a ``.jsonl`` transcript, runs the oracle over its parsed events, and returns a
|
|
6
6
|
:class:`SessionActivityProbe`; :func:`probe_events` is the Python reference the Rust probe
|
|
7
|
-
stays identical to. The verdict spans
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
7
|
+
stays identical to. The verdict spans undelivered task notifications (the
|
|
8
|
+
:class:`~cc_transcript.notifications.Notifications` queue replay), ephemeral waits
|
|
9
|
+
(waiting tools, backgrounded Agent/Task/Bash, subagentless Agent/Task) over the current
|
|
10
|
+
turn, and pending async launches (async Agent/Task, live Workflow) session-wide — a
|
|
11
|
+
launch counts as completed only once its notification was delivered or drained, never
|
|
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.
|
|
12
15
|
"""
|
|
13
16
|
|
|
14
17
|
from __future__ import annotations
|
|
@@ -20,12 +23,12 @@ from typing import TYPE_CHECKING, Literal
|
|
|
20
23
|
from cc_transcript.activity import native_user_classifier
|
|
21
24
|
from cc_transcript.models import (
|
|
22
25
|
AssistantEvent,
|
|
23
|
-
OtherEvent,
|
|
24
26
|
SystemEvent,
|
|
25
27
|
ToolResultBlock,
|
|
26
28
|
ToolUseBlock,
|
|
27
29
|
UserEvent,
|
|
28
30
|
)
|
|
31
|
+
from cc_transcript.notifications import Notifications
|
|
29
32
|
from cc_transcript.parser import parse_events_from_bytes
|
|
30
33
|
|
|
31
34
|
if TYPE_CHECKING:
|
|
@@ -81,11 +84,6 @@ class SessionActivityProbe:
|
|
|
81
84
|
last_event_epoch: int | None
|
|
82
85
|
|
|
83
86
|
|
|
84
|
-
def completed(completions: Sequence[str], tool_use_id: str) -> bool:
|
|
85
|
-
marker = f"<tool-use-id>{tool_use_id}</tool-use-id>"
|
|
86
|
-
return any(marker in content for content in completions)
|
|
87
|
-
|
|
88
|
-
|
|
89
87
|
def ephemeral_wait(block: ToolUseBlock, waiting_tools: frozenset[str]) -> PendingKind | None:
|
|
90
88
|
if block.name in waiting_tools:
|
|
91
89
|
return "waiting_tool"
|
|
@@ -98,12 +96,12 @@ def ephemeral_wait(block: ToolUseBlock, waiting_tools: frozenset[str]) -> Pendin
|
|
|
98
96
|
|
|
99
97
|
|
|
100
98
|
def pending_async(
|
|
101
|
-
block: ToolUseBlock, result: ToolResultBlock | None,
|
|
99
|
+
block: ToolUseBlock, result: ToolResultBlock | None, notifications: Notifications
|
|
102
100
|
) -> PendingKind | None:
|
|
103
101
|
match block.name:
|
|
104
|
-
case "Agent" | "Task" if result is not None and result.is_async and not completed(
|
|
102
|
+
case "Agent" | "Task" if result is not None and result.is_async and not notifications.completed(block.id):
|
|
105
103
|
return "pending_async_task"
|
|
106
|
-
case "Workflow" if not completed(
|
|
104
|
+
case "Workflow" if not notifications.completed(block.id):
|
|
107
105
|
return "pending_async_workflow"
|
|
108
106
|
return None
|
|
109
107
|
|
|
@@ -114,7 +112,11 @@ def probe_events(
|
|
|
114
112
|
waiting_tools: frozenset[str] = DEFAULT_WAITING_TOOLS,
|
|
115
113
|
human_facing_tools: frozenset[str] = DEFAULT_HUMAN_FACING_TOOLS,
|
|
116
114
|
) -> SessionActivityProbe:
|
|
117
|
-
"""The Python reference oracle over parsed events, twin of the Rust probe.
|
|
115
|
+
"""The Python reference oracle over parsed events, twin of the Rust probe.
|
|
116
|
+
|
|
117
|
+
An undelivered task notification alone sets ``is_waiting`` with no pending
|
|
118
|
+
item — a resumed session's orphan has no launch to point at.
|
|
119
|
+
"""
|
|
118
120
|
results = {
|
|
119
121
|
block.tool_use_id: block
|
|
120
122
|
for event in events
|
|
@@ -122,12 +124,7 @@ def probe_events(
|
|
|
122
124
|
for block in event.blocks
|
|
123
125
|
if isinstance(block, ToolResultBlock)
|
|
124
126
|
}
|
|
125
|
-
|
|
126
|
-
content
|
|
127
|
-
for event in events
|
|
128
|
-
if isinstance(event, OtherEvent) and event.type == "queue-operation"
|
|
129
|
-
if isinstance(content := event.raw.get("content"), str)
|
|
130
|
-
]
|
|
127
|
+
notifications = Notifications.from_events(events)
|
|
131
128
|
turn_start = next(
|
|
132
129
|
(
|
|
133
130
|
index
|
|
@@ -137,7 +134,7 @@ def probe_events(
|
|
|
137
134
|
0,
|
|
138
135
|
)
|
|
139
136
|
|
|
140
|
-
is_waiting =
|
|
137
|
+
is_waiting = notifications.has_pending
|
|
141
138
|
mid_tool = False
|
|
142
139
|
pending: list[PendingItem] = []
|
|
143
140
|
seen: set[str] = set()
|
|
@@ -152,7 +149,7 @@ def probe_events(
|
|
|
152
149
|
continue
|
|
153
150
|
in_current_turn = index >= turn_start
|
|
154
151
|
waiting_kind = (ephemeral_wait(block, waiting_tools) if in_current_turn else None) or pending_async(
|
|
155
|
-
block, result,
|
|
152
|
+
block, result, notifications
|
|
156
153
|
)
|
|
157
154
|
unmatched = in_current_turn and result is None and block.name not in human_facing_tools
|
|
158
155
|
is_waiting = is_waiting or waiting_kind is not None
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
[package]
|
|
2
2
|
name = "cc_transcript_parser"
|
|
3
|
-
version = "0.
|
|
3
|
+
version = "0.9.0"
|
|
4
4
|
edition = "2021"
|
|
5
5
|
|
|
6
6
|
[lib]
|
|
@@ -20,7 +20,6 @@ python = [
|
|
|
20
20
|
"dep:tree-sitter-bash",
|
|
21
21
|
]
|
|
22
22
|
extension-module = ["python", "pyo3/extension-module"]
|
|
23
|
-
swift = []
|
|
24
23
|
|
|
25
24
|
[dependencies]
|
|
26
25
|
pyo3 = { version = "0.28", features = ["abi3-py313", "chrono"], optional = true }
|
|
@@ -38,3 +37,6 @@ dirs = { version = "5", optional = true }
|
|
|
38
37
|
# bash grammar), tree-sitter core 0.26 matches the wheel's tree-sitter 0.26.0.
|
|
39
38
|
tree-sitter = { version = "0.26", optional = true }
|
|
40
39
|
tree-sitter-bash = { version = "0.25", optional = true }
|
|
40
|
+
|
|
41
|
+
[build-dependencies]
|
|
42
|
+
pyo3-build-config = { version = "0.28", features = ["resolve-config"] }
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
// Local `cargo test` on the default (python) feature links a shared libpython
|
|
2
|
+
// whose install name is `@rpath/libpython3.X.dylib`. The cargo test harness
|
|
3
|
+
// carries no rpath to the interpreter's lib dir, so the binary aborts at load.
|
|
4
|
+
// Emit that rpath, derived from the same interpreter pyo3 linked. Gated off when
|
|
5
|
+
// `extension-module` is active, so the shipped wheel (which links no libpython
|
|
6
|
+
// and resolves symbols from the host interpreter) keeps its exact build flags.
|
|
7
|
+
fn main() {
|
|
8
|
+
let python = std::env::var_os("CARGO_FEATURE_PYTHON").is_some();
|
|
9
|
+
let extension_module = std::env::var_os("CARGO_FEATURE_EXTENSION_MODULE").is_some();
|
|
10
|
+
if !python || extension_module {
|
|
11
|
+
return;
|
|
12
|
+
}
|
|
13
|
+
println!("cargo:rerun-if-env-changed=PYO3_PYTHON");
|
|
14
|
+
println!("cargo:rerun-if-env-changed=PYO3_CONFIG_FILE");
|
|
15
|
+
let lib_dir = pyo3_build_config::get()
|
|
16
|
+
.lib_dir
|
|
17
|
+
.as_ref()
|
|
18
|
+
.expect("pyo3 linked a shared libpython but reported no lib_dir for the test rpath");
|
|
19
|
+
println!("cargo:rustc-link-arg=-Wl,-rpath,{lib_dir}");
|
|
20
|
+
}
|
|
@@ -1,7 +1,9 @@
|
|
|
1
1
|
use std::collections::{HashMap, HashSet};
|
|
2
2
|
|
|
3
3
|
use crate::types::{Entry, ToolResultBlock, ToolUseBlock, UserEntry};
|
|
4
|
-
use crate::value::field_str;
|
|
4
|
+
use crate::value::{field, field_str};
|
|
5
|
+
|
|
6
|
+
const NOTIFICATION_MARKER: &str = "<task-notification>";
|
|
5
7
|
|
|
6
8
|
pub struct ActivityOpts {
|
|
7
9
|
pub waiting_tools: HashSet<String>,
|
|
@@ -59,9 +61,10 @@ pub struct SessionActivity {
|
|
|
59
61
|
|
|
60
62
|
/// Whether a user entry opens a turn (activity.py ``native_user_classifier``):
|
|
61
63
|
/// a real prompt — non-meta, non-sidechain, not an interruption marker, with
|
|
62
|
-
/// non-blank text.
|
|
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.
|
|
63
66
|
fn opens_turn(user: &UserEntry) -> bool {
|
|
64
|
-
!(user.meta.is_meta || user.meta.is_sidechain || user.interrupted())
|
|
67
|
+
!(user.meta.is_meta || user.meta.is_sidechain || user.meta.is_compact_summary || user.interrupted())
|
|
65
68
|
&& !user.content.text().trim().is_empty()
|
|
66
69
|
}
|
|
67
70
|
|
|
@@ -90,51 +93,114 @@ fn ephemeral_wait(tool_use: &ToolUseBlock, waiting_tools: &HashSet<String>) -> O
|
|
|
90
93
|
}
|
|
91
94
|
|
|
92
95
|
/// conditions.py ``pending_async``: an async Agent/Task launch or a live
|
|
93
|
-
/// Workflow
|
|
96
|
+
/// Workflow whose completion notification has not reached the agent.
|
|
94
97
|
fn pending_async(
|
|
95
98
|
tool_use: &ToolUseBlock,
|
|
96
99
|
result: Option<&ToolResultBlock>,
|
|
97
|
-
|
|
100
|
+
notifications: &Notifications,
|
|
98
101
|
) -> Option<PendingKind> {
|
|
99
102
|
match tool_use.name.as_str() {
|
|
100
103
|
"Agent" | "Task"
|
|
101
|
-
if result.is_some_and(|r| r.is_async) && !completed(
|
|
104
|
+
if result.is_some_and(|r| r.is_async) && !notifications.completed(&tool_use.id) =>
|
|
102
105
|
{
|
|
103
106
|
Some(PendingKind::PendingAsyncTask)
|
|
104
107
|
}
|
|
105
|
-
"Workflow" if !completed(
|
|
108
|
+
"Workflow" if !notifications.completed(&tool_use.id) => {
|
|
109
|
+
Some(PendingKind::PendingAsyncWorkflow)
|
|
110
|
+
}
|
|
106
111
|
_ => None,
|
|
107
112
|
}
|
|
108
113
|
}
|
|
109
114
|
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
115
|
-
|
|
116
|
-
})
|
|
117
|
-
.collect()
|
|
115
|
+
/// notifications.py ``Notifications``: the harness delivery queue replayed
|
|
116
|
+
/// from the transcript's ``queue-operation`` audit records.
|
|
117
|
+
struct Notifications {
|
|
118
|
+
queued: Vec<String>,
|
|
119
|
+
delivered: Vec<String>,
|
|
120
|
+
enqueued: Vec<String>,
|
|
118
121
|
}
|
|
119
122
|
|
|
120
|
-
|
|
121
|
-
|
|
122
|
-
|
|
123
|
+
impl Notifications {
|
|
124
|
+
fn from_entries(entries: &[Entry]) -> Self {
|
|
125
|
+
let mut queued: Vec<String> = Vec::new();
|
|
126
|
+
let mut delivered: Vec<String> = Vec::new();
|
|
127
|
+
let mut enqueued: Vec<String> = Vec::new();
|
|
128
|
+
for entry in entries {
|
|
129
|
+
if let Entry::Other(other) = entry {
|
|
130
|
+
if other.ty == "queue-operation" {
|
|
131
|
+
match field_str(&other.raw, "operation") {
|
|
132
|
+
Some("enqueue") => {
|
|
133
|
+
let content =
|
|
134
|
+
field_str(&other.raw, "content").unwrap_or_default().to_string();
|
|
135
|
+
enqueued.push(content.clone());
|
|
136
|
+
queued.push(content);
|
|
137
|
+
}
|
|
138
|
+
Some("dequeue" | "remove") if !queued.is_empty() => {
|
|
139
|
+
queued.remove(0);
|
|
140
|
+
}
|
|
141
|
+
Some("popAll") => {
|
|
142
|
+
let content = field_str(&other.raw, "content").unwrap_or_default();
|
|
143
|
+
queued.retain(|item| !content.contains(item.as_str()));
|
|
144
|
+
}
|
|
145
|
+
_ => {}
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
}
|
|
149
|
+
if let Some(text) = delivered_text(entry) {
|
|
150
|
+
delivered.push(text);
|
|
151
|
+
}
|
|
152
|
+
}
|
|
153
|
+
Self { queued, delivered, enqueued }
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
/// notifications.py ``Notifications.completed``: delivered, or enqueued at
|
|
157
|
+
/// some point yet no longer sitting undelivered in the queue.
|
|
158
|
+
fn completed(&self, tool_use_id: &str) -> bool {
|
|
159
|
+
let marker = format!("<tool-use-id>{tool_use_id}</tool-use-id>");
|
|
160
|
+
let holds = |texts: &[String]| texts.iter().any(|text| text.contains(&marker));
|
|
161
|
+
holds(&self.delivered) || (holds(&self.enqueued) && !holds(&self.queued))
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
/// notifications.py ``Notifications.has_pending``: any queued item is an
|
|
165
|
+
/// undelivered task notification.
|
|
166
|
+
fn has_pending(&self) -> bool {
|
|
167
|
+
self.queued.iter().any(|text| text.contains(NOTIFICATION_MARKER))
|
|
168
|
+
}
|
|
169
|
+
}
|
|
170
|
+
|
|
171
|
+
/// notifications.py ``delivered_text``: a user turn carrying a task
|
|
172
|
+
/// notification, or a ``queued_command`` attachment replayed to the model.
|
|
173
|
+
fn delivered_text(entry: &Entry) -> Option<String> {
|
|
174
|
+
match entry {
|
|
175
|
+
Entry::User(user) => {
|
|
176
|
+
let text = user.content.text();
|
|
177
|
+
text.contains(NOTIFICATION_MARKER).then_some(text)
|
|
178
|
+
}
|
|
179
|
+
Entry::Other(other) if other.ty == "attachment" => {
|
|
180
|
+
let attachment = field(&other.raw, "attachment")?;
|
|
181
|
+
(field_str(attachment, "type") == Some("queued_command"))
|
|
182
|
+
.then(|| field_str(attachment, "prompt").unwrap_or_default().to_string())
|
|
183
|
+
}
|
|
184
|
+
_ => None,
|
|
185
|
+
}
|
|
123
186
|
}
|
|
124
187
|
|
|
125
188
|
/// The session-activity oracle over parsed entries: captain-hook's
|
|
126
|
-
/// ``is_waiting`` verdict
|
|
127
|
-
///
|
|
189
|
+
/// ``is_waiting`` verdict (post-d2e07cc, minus its hook-side Stop-payload
|
|
190
|
+
/// layer) over undelivered notifications, ephemeral waits, and pending async
|
|
191
|
+
/// launches, plus the mid-tool flag and the contributing tool calls. An
|
|
192
|
+
/// undelivered notification alone sets ``is_waiting`` with no pending item —
|
|
193
|
+
/// a resumed session's orphan has no launch to point at.
|
|
128
194
|
pub fn session_activity(entries: &[Entry], opts: &ActivityOpts) -> SessionActivity {
|
|
129
195
|
let results: HashMap<&str, &ToolResultBlock> = entries
|
|
130
196
|
.iter()
|
|
131
197
|
.flat_map(Entry::tool_results)
|
|
132
198
|
.map(|result| (result.tool_use_id.as_str(), result))
|
|
133
199
|
.collect();
|
|
134
|
-
let
|
|
200
|
+
let notifications = Notifications::from_entries(entries);
|
|
135
201
|
let turn_start = current_turn_start(entries);
|
|
136
202
|
|
|
137
|
-
let mut is_waiting =
|
|
203
|
+
let mut is_waiting = notifications.has_pending();
|
|
138
204
|
let mut mid_tool = false;
|
|
139
205
|
let mut pending: Vec<PendingItem> = Vec::new();
|
|
140
206
|
let mut seen: HashSet<&str> = HashSet::new();
|
|
@@ -148,7 +214,7 @@ pub fn session_activity(entries: &[Entry], opts: &ActivityOpts) -> SessionActivi
|
|
|
148
214
|
let waiting_kind = in_current_turn
|
|
149
215
|
.then(|| ephemeral_wait(tool_use, &opts.waiting_tools))
|
|
150
216
|
.flatten()
|
|
151
|
-
.or_else(|| pending_async(tool_use, result, &
|
|
217
|
+
.or_else(|| pending_async(tool_use, result, ¬ifications));
|
|
152
218
|
let unmatched = in_current_turn
|
|
153
219
|
&& result.is_none()
|
|
154
220
|
&& !opts.human_facing_tools.contains(&tool_use.name);
|
|
@@ -215,12 +281,27 @@ mod tests {
|
|
|
215
281
|
}
|
|
216
282
|
|
|
217
283
|
fn queue_op(content: &str) -> Entry {
|
|
284
|
+
queue_entry("enqueue", content)
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
fn queue_entry(operation: &str, content: &str) -> Entry {
|
|
218
288
|
parse(&format!(
|
|
219
|
-
r#"{{"type":"queue-operation","operation":"
|
|
289
|
+
r#"{{"type":"queue-operation","operation":"{operation}","content":{}}}"#,
|
|
220
290
|
sonic_rs::to_string(&content).unwrap()
|
|
221
291
|
))
|
|
222
292
|
}
|
|
223
293
|
|
|
294
|
+
fn attachment(prompt: &str) -> Entry {
|
|
295
|
+
parse(&format!(
|
|
296
|
+
r#"{{"type":"attachment","attachment":{{"type":"queued_command","prompt":{}}}}}"#,
|
|
297
|
+
sonic_rs::to_string(&prompt).unwrap()
|
|
298
|
+
))
|
|
299
|
+
}
|
|
300
|
+
|
|
301
|
+
fn delivered_notification(id: &str) -> Vec<Entry> {
|
|
302
|
+
vec![queue_op(¬ification(id)), queue_entry("dequeue", ""), user(¬ification(id))]
|
|
303
|
+
}
|
|
304
|
+
|
|
224
305
|
fn notification(id: &str) -> String {
|
|
225
306
|
format!(
|
|
226
307
|
"<task-notification><task-id>t</task-id><tool-use-id>{id}</tool-use-id><status>completed</status></task-notification>"
|
|
@@ -252,18 +333,169 @@ mod tests {
|
|
|
252
333
|
}
|
|
253
334
|
|
|
254
335
|
#[test]
|
|
255
|
-
fn
|
|
336
|
+
fn workflow_delivered_notification_clears_waiting() {
|
|
337
|
+
let mut entries = vec![
|
|
338
|
+
user("run the workflow"),
|
|
339
|
+
tool_use("Workflow", "wf1", r#"{"script":"return 1"}"#),
|
|
340
|
+
tool_result("wf1"),
|
|
341
|
+
];
|
|
342
|
+
entries.extend(delivered_notification("wf1"));
|
|
343
|
+
let activity = activity(&entries);
|
|
344
|
+
assert!(!activity.is_waiting);
|
|
345
|
+
assert!(activity.pending.is_empty());
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
#[test]
|
|
349
|
+
fn enqueued_undelivered_notification_keeps_waiting() {
|
|
350
|
+
let entries = vec![
|
|
351
|
+
user("run the workflow"),
|
|
352
|
+
tool_use("Workflow", "wf1", r#"{"script":"return 1"}"#),
|
|
353
|
+
tool_result("wf1"),
|
|
354
|
+
queue_op(¬ification("wf1")),
|
|
355
|
+
];
|
|
356
|
+
let activity = activity(&entries);
|
|
357
|
+
assert!(activity.is_waiting);
|
|
358
|
+
assert_eq!(
|
|
359
|
+
activity.pending,
|
|
360
|
+
[PendingItem {
|
|
361
|
+
tool_use_id: Some("wf1".to_string()),
|
|
362
|
+
name: "Workflow".to_string(),
|
|
363
|
+
kind: PendingKind::PendingAsyncWorkflow
|
|
364
|
+
}]
|
|
365
|
+
);
|
|
366
|
+
}
|
|
367
|
+
|
|
368
|
+
#[test]
|
|
369
|
+
fn removed_notification_counts_completed() {
|
|
256
370
|
let entries = vec![
|
|
257
371
|
user("run the workflow"),
|
|
258
372
|
tool_use("Workflow", "wf1", r#"{"script":"return 1"}"#),
|
|
259
373
|
tool_result("wf1"),
|
|
260
374
|
queue_op(¬ification("wf1")),
|
|
375
|
+
queue_entry("remove", ""),
|
|
261
376
|
];
|
|
262
377
|
let activity = activity(&entries);
|
|
263
378
|
assert!(!activity.is_waiting);
|
|
264
379
|
assert!(activity.pending.is_empty());
|
|
265
380
|
}
|
|
266
381
|
|
|
382
|
+
#[test]
|
|
383
|
+
fn popall_drains_command_but_not_notification() {
|
|
384
|
+
let notif = notification("wf1");
|
|
385
|
+
let entries = vec![
|
|
386
|
+
user("run the workflow"),
|
|
387
|
+
tool_use("Workflow", "wf1", r#"{"script":"return 1"}"#),
|
|
388
|
+
tool_result("wf1"),
|
|
389
|
+
queue_op(¬if),
|
|
390
|
+
queue_op("run the tests please"),
|
|
391
|
+
queue_entry("popAll", "run the tests please"),
|
|
392
|
+
];
|
|
393
|
+
let notifications = Notifications::from_entries(&entries);
|
|
394
|
+
assert_eq!(
|
|
395
|
+
notifications.queued,
|
|
396
|
+
vec![notif],
|
|
397
|
+
"popAll drains the matching command yet leaves the notification queued"
|
|
398
|
+
);
|
|
399
|
+
assert!(notifications.has_pending());
|
|
400
|
+
let activity = activity(&entries);
|
|
401
|
+
assert!(activity.is_waiting);
|
|
402
|
+
assert_eq!(
|
|
403
|
+
activity.pending,
|
|
404
|
+
[PendingItem {
|
|
405
|
+
tool_use_id: Some("wf1".to_string()),
|
|
406
|
+
name: "Workflow".to_string(),
|
|
407
|
+
kind: PendingKind::PendingAsyncWorkflow
|
|
408
|
+
}]
|
|
409
|
+
);
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
#[test]
|
|
413
|
+
fn popall_drains_the_notification_completes() {
|
|
414
|
+
let notif = notification("wf1");
|
|
415
|
+
let entries = vec![
|
|
416
|
+
user("run the workflow"),
|
|
417
|
+
tool_use("Workflow", "wf1", r#"{"script":"return 1"}"#),
|
|
418
|
+
tool_result("wf1"),
|
|
419
|
+
queue_op(¬if),
|
|
420
|
+
queue_entry("popAll", ¬if),
|
|
421
|
+
];
|
|
422
|
+
let notifications = Notifications::from_entries(&entries);
|
|
423
|
+
assert!(
|
|
424
|
+
notifications.queued.is_empty(),
|
|
425
|
+
"popAll subtracts the notification itself out of the queue"
|
|
426
|
+
);
|
|
427
|
+
assert!(
|
|
428
|
+
notifications.completed("wf1"),
|
|
429
|
+
"an enqueued-then-drained notification counts as completed"
|
|
430
|
+
);
|
|
431
|
+
assert!(!notifications.has_pending());
|
|
432
|
+
let activity = activity(&entries);
|
|
433
|
+
assert!(!activity.is_waiting, "draining the notification closes the workflow's async wait");
|
|
434
|
+
assert!(!activity.mid_tool);
|
|
435
|
+
assert!(activity.pending.is_empty());
|
|
436
|
+
}
|
|
437
|
+
|
|
438
|
+
#[test]
|
|
439
|
+
fn orphan_undelivered_notification_is_waiting() {
|
|
440
|
+
let entries = vec![user("hi"), queue_op(¬ification("tu_ghost"))];
|
|
441
|
+
let activity = activity(&entries);
|
|
442
|
+
assert!(activity.is_waiting, "a queued task notification holds the session on its own");
|
|
443
|
+
assert!(!activity.mid_tool);
|
|
444
|
+
assert!(activity.pending.is_empty());
|
|
445
|
+
}
|
|
446
|
+
|
|
447
|
+
#[test]
|
|
448
|
+
fn attachment_delivery_completes() {
|
|
449
|
+
let entries = vec![
|
|
450
|
+
user("go"),
|
|
451
|
+
tool_use("Agent", "a1", r#"{"subagent_type":"Explore","prompt":"look"}"#),
|
|
452
|
+
result_entry("a1", false, true),
|
|
453
|
+
attachment(¬ification("a1")),
|
|
454
|
+
];
|
|
455
|
+
let activity = activity(&entries);
|
|
456
|
+
assert!(!activity.is_waiting, "the queued_command attachment alone closes the async task");
|
|
457
|
+
assert!(!activity.mid_tool);
|
|
458
|
+
assert!(activity.pending.is_empty());
|
|
459
|
+
let expected = chrono::DateTime::parse_from_rfc3339("2026-01-02T03:04:07Z")
|
|
460
|
+
.unwrap()
|
|
461
|
+
.timestamp();
|
|
462
|
+
assert_eq!(activity.last_event_epoch, Some(expected));
|
|
463
|
+
}
|
|
464
|
+
|
|
465
|
+
#[test]
|
|
466
|
+
fn plain_user_delivery_completes() {
|
|
467
|
+
let entries = vec![
|
|
468
|
+
user("go"),
|
|
469
|
+
tool_use("Agent", "a1", r#"{"subagent_type":"Explore","prompt":"look"}"#),
|
|
470
|
+
result_entry("a1", false, true),
|
|
471
|
+
user(¬ification("a1")),
|
|
472
|
+
];
|
|
473
|
+
let activity = activity(&entries);
|
|
474
|
+
assert!(!activity.is_waiting, "a user turn carrying the notification closes the async task");
|
|
475
|
+
assert!(!activity.mid_tool);
|
|
476
|
+
assert!(activity.pending.is_empty());
|
|
477
|
+
let expected = chrono::DateTime::parse_from_rfc3339("2026-01-02T03:04:07Z")
|
|
478
|
+
.unwrap()
|
|
479
|
+
.timestamp();
|
|
480
|
+
assert_eq!(activity.last_event_epoch, Some(expected));
|
|
481
|
+
}
|
|
482
|
+
|
|
483
|
+
#[test]
|
|
484
|
+
fn compact_summary_user_does_not_open_turn() {
|
|
485
|
+
let entries = vec![
|
|
486
|
+
user("build it"),
|
|
487
|
+
tool_use("Bash", "b1", r#"{"command":"make","run_in_background":true}"#),
|
|
488
|
+
tool_result("b1"),
|
|
489
|
+
user_with("compact recap", r#""isCompactSummary":true,"#),
|
|
490
|
+
];
|
|
491
|
+
let activity = activity(&entries);
|
|
492
|
+
assert!(
|
|
493
|
+
activity.is_waiting,
|
|
494
|
+
"auto-compaction must not retire a running background task"
|
|
495
|
+
);
|
|
496
|
+
assert_eq!(activity.pending[0].kind, PendingKind::Background);
|
|
497
|
+
}
|
|
498
|
+
|
|
267
499
|
#[test]
|
|
268
500
|
fn unrelated_completion_marker_keeps_waiting() {
|
|
269
501
|
let entries = vec![
|
|
@@ -349,14 +581,14 @@ mod tests {
|
|
|
349
581
|
}
|
|
350
582
|
|
|
351
583
|
#[test]
|
|
352
|
-
fn
|
|
353
|
-
let entries = vec![
|
|
584
|
+
fn async_agent_delivered_notification_clears_waiting() {
|
|
585
|
+
let mut entries = vec![
|
|
354
586
|
user("go"),
|
|
355
587
|
tool_use("Agent", "a1", r#"{"subagent_type":"Explore","prompt":"look"}"#),
|
|
356
588
|
result_entry("a1", false, true),
|
|
357
589
|
user("while that runs, plan"),
|
|
358
|
-
queue_op(¬ification("a1")),
|
|
359
590
|
];
|
|
591
|
+
entries.extend(delivered_notification("a1"));
|
|
360
592
|
assert!(!activity(&entries).is_waiting);
|
|
361
593
|
}
|
|
362
594
|
|
|
@@ -8,7 +8,7 @@ use regex::Regex;
|
|
|
8
8
|
use sonic_rs::{Index, JsonContainerTrait, JsonValueTrait, Value};
|
|
9
9
|
|
|
10
10
|
use crate::filter::compile_group_array;
|
|
11
|
-
use crate::parse::{
|
|
11
|
+
use crate::parse::{parse_bytes, ParseError};
|
|
12
12
|
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,
|
|
@@ -434,13 +434,7 @@ impl Events {
|
|
|
434
434
|
/// non-object values (parser.py decode_line) so event indices agree with the
|
|
435
435
|
/// Python reference; a malformed entry fails the mine, as in the reference.
|
|
436
436
|
fn parse(raw: &[u8]) -> Result<Self, ParseError> {
|
|
437
|
-
let entries = raw
|
|
438
|
-
.split(|&b| b == b'\n')
|
|
439
|
-
.filter(|line| !line.iter().all(u8::is_ascii_whitespace))
|
|
440
|
-
.filter_map(|line| sonic_rs::from_slice::<Value>(line).ok())
|
|
441
|
-
.filter(|value| value.is_object())
|
|
442
|
-
.map(parse_entry)
|
|
443
|
-
.collect::<Result<Vec<_>, _>>()?;
|
|
437
|
+
let entries = parse_bytes(raw, |_| true)?;
|
|
444
438
|
let texts = entries
|
|
445
439
|
.iter()
|
|
446
440
|
.map(|entry| match entry {
|
|
@@ -265,9 +265,10 @@ pub fn parse_entry(data: Value) -> Result<Entry, ParseError> {
|
|
|
265
265
|
Ok(Entry::Other(OtherEntry { ty, raw: data }))
|
|
266
266
|
}
|
|
267
267
|
|
|
268
|
-
//
|
|
269
|
-
//
|
|
270
|
-
//
|
|
268
|
+
// Non-JSON lines and valid-JSON lines that are not objects (bare scalars or
|
|
269
|
+
// arrays) are skipped; a JSON object that fails the typed parse (e.g. a missing
|
|
270
|
+
// required field) fails the whole file — whole-file parity with PythonBackend,
|
|
271
|
+
// which decodes every line, skips non-objects, then parses the rest.
|
|
271
272
|
fn parse_line<F: Fn(&Entry) -> bool>(
|
|
272
273
|
line: &[u8],
|
|
273
274
|
lines: &mut Vec<Entry>,
|
|
@@ -277,6 +278,9 @@ fn parse_line<F: Fn(&Entry) -> bool>(
|
|
|
277
278
|
return Ok(());
|
|
278
279
|
}
|
|
279
280
|
if let Ok(value) = sonic_rs::from_slice::<Value>(line) {
|
|
281
|
+
if !value.is_object() {
|
|
282
|
+
return Ok(());
|
|
283
|
+
}
|
|
280
284
|
let entry = parse_entry(value)?;
|
|
281
285
|
if keep(&entry) {
|
|
282
286
|
lines.push(entry);
|
|
@@ -614,6 +618,15 @@ mod tests {
|
|
|
614
618
|
assert_eq!(other.raw, parse(raw));
|
|
615
619
|
}
|
|
616
620
|
|
|
621
|
+
#[test]
|
|
622
|
+
fn parse_bytes_skips_valid_json_non_object_lines() {
|
|
623
|
+
let real = format!(r#"{{"type":"user",{META},"message":{{"content":"hi"}}}}"#);
|
|
624
|
+
let bytes = format!("{real}\n42\n\"bare\"\n[1,2,3]\n{real}");
|
|
625
|
+
let entries = parse_bytes(bytes.as_bytes(), |_| true).unwrap();
|
|
626
|
+
assert_eq!(entries.len(), 2, "bare scalar and array lines are skipped");
|
|
627
|
+
assert!(entries.iter().all(|e| matches!(e, Entry::User(_))));
|
|
628
|
+
}
|
|
629
|
+
|
|
617
630
|
#[test]
|
|
618
631
|
fn print_envelope_requires_result_element() {
|
|
619
632
|
assert!(matches!(
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|
|
File without changes
|