cc-transcript 10.0.0__tar.gz → 10.1.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.1.0}/Cargo.lock +3 -2
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/PKG-INFO +1 -1
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/activity_probe.py +23 -24
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/pyproject.toml +1 -1
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/rust/Cargo.toml +4 -2
- cc_transcript-10.1.0/rust/build.rs +20 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/rust/src/activity.rs +260 -28
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/Cargo.toml +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/LICENSE +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/README.md +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/__init__.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/__main__.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/_parser_rs.pyi +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/activity.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/backend.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/builders.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/cli.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/command.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/context.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/corrections.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/corrections_cli.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/cost.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/decisions.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/discovery.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/disktruth.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/evidence.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/extract/__init__.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/extract/correct.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/facts.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/filterspec.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/ids.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/judge/__init__.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/judge/llm.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/judge/similar.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/judge/verdicts.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/mining/__init__.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/mining/candidates.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/mining/confidence.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/mining/engine.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/mining/filterspec.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/mining/formats.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/mining/signals.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/mining/sourcekind.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/mining/spec.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/mining/store.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/models.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/notifications.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/parser.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/py.typed +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/query.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/render.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/rust.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/sentiment/__init__.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/sentiment/buckets.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/sentiment/engine.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/sentiment/lexicon.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/sentiment/scorespec.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/store.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/cc_transcript/tools.py +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/rust/data/afinn-en-165.tsv +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/rust/data/domain_overrides.tsv +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/rust/src/command.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/rust/src/event.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/rust/src/filter.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/rust/src/lexicon.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/rust/src/lib.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/rust/src/mining.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/rust/src/model.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/rust/src/parse.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/rust/src/protocol.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/rust/src/python.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/rust/src/score.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.0}/rust/src/types.rs +0 -0
- {cc_transcript-10.0.0 → cc_transcript-10.1.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",
|
|
@@ -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,22 +124,19 @@ 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
|
|
134
131
|
for index in reversed(range(len(events)))
|
|
135
|
-
if isinstance(event := events[index], UserEvent)
|
|
132
|
+
if isinstance(event := events[index], UserEvent)
|
|
133
|
+
and native_user_classifier(event)
|
|
134
|
+
and not event.meta.is_compact_summary
|
|
136
135
|
),
|
|
137
136
|
0,
|
|
138
137
|
)
|
|
139
138
|
|
|
140
|
-
is_waiting =
|
|
139
|
+
is_waiting = notifications.has_pending
|
|
141
140
|
mid_tool = False
|
|
142
141
|
pending: list[PendingItem] = []
|
|
143
142
|
seen: set[str] = set()
|
|
@@ -152,7 +151,7 @@ def probe_events(
|
|
|
152
151
|
continue
|
|
153
152
|
in_current_turn = index >= turn_start
|
|
154
153
|
waiting_kind = (ephemeral_wait(block, waiting_tools) if in_current_turn else None) or pending_async(
|
|
155
|
-
block, result,
|
|
154
|
+
block, result, notifications
|
|
156
155
|
)
|
|
157
156
|
unmatched = in_current_turn and result is None and block.name not in human_facing_tools
|
|
158
157
|
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
|
|
|
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
|
|
File without changes
|
|
File without changes
|
|
File without changes
|