Skip to content

Commit 3fd9f01

Browse files
committed
feat: WatcherEvent convenience methods and provider-namespaced extras
toolpath-convo 0.5.0: - Added as_turn(), as_progress(), is_update(), turn_id() on WatcherEvent - Documented Turn.extra provider-namespacing convention toolpath-claude 0.6.2: - Populate Turn.extra["claude"] from ConversationEntry.extra - Enrich Progress.data["claude"] with full entry payload - Both additive — previously-empty fields now carry data
1 parent cabfaed commit 3fd9f01

11 files changed

Lines changed: 299 additions & 19 deletions

File tree

CHANGELOG.md

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,23 @@
22

33
All notable changes to the Toolpath workspace are documented here.
44

5+
## 0.5.0 — toolpath-convo / 0.6.2 — toolpath-claude
6+
7+
### toolpath-convo 0.5.0
8+
9+
- Added `WatcherEvent::as_turn()` — returns the `Turn` payload for both `Turn` and `TurnUpdated` variants
10+
- Added `WatcherEvent::as_progress()` — returns `(kind, data)` for `Progress` events
11+
- Added `WatcherEvent::is_update()` — returns `true` only for `TurnUpdated`
12+
- Added `WatcherEvent::turn_id()` — returns the turn ID for turn-carrying variants
13+
- Added dispatch loop example to `WatcherEvent` rustdoc
14+
15+
### toolpath-claude 0.6.2
16+
17+
- `to_turn()` now populates `Turn.extra["claude"]` with provider-specific metadata from `ConversationEntry.extra` (e.g. `subtype`, `data`), enabling trait-only consumers to access state-inference signals without importing provider types
18+
- `WatcherEvent::Progress` events now include the full entry payload under `data["claude"]`, carrying fields like `data.type`, `data.hookName`, `data.agentId`, and `data.message` that were previously discarded
19+
- Both changes are additive — previously-empty fields are now populated; no existing behavior changes
20+
- Thanks to the crabcity maintainers for the detailed gap analysis
21+
522
## 0.6.1 — toolpath-claude
623

724
### toolpath-claude 0.6.1

CLAUDE.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -123,3 +123,4 @@ Build the site after changes: `cd site && pnpm run build` (should produce 7 page
123123
- The git derivation (`toolpath-git`) uses `git2` (libgit2 bindings), not shelling out to git
124124
- Claude conversation data lives in `~/.claude/projects/` as JSONL files; `toolpath-claude` reads these directly
125125
- `toolpath-claude` follows session chains by default — Claude Code rotates JSONL files on context overflow; `read_conversation` merges segments, `list_conversations` returns chain heads. `read_segment`/`list_segments` for single-file access. `ChainIndex` makes this incremental.
126+
- Provider-specific extras convention: `Turn.extra` and `WatcherEvent::Progress.data` use provider-namespaced keys (e.g. `extra["claude"]`). `toolpath-claude` populates `Turn.extra["claude"]` from `ConversationEntry.extra` and `Progress.data["claude"]` from the full entry payload. This lets trait-only consumers access provider metadata (like `subtype` for state inference) without importing provider types.

Cargo.lock

Lines changed: 2 additions & 2 deletions
Some generated files are not rendered by default. Learn more about customizing how changed files appear on GitHub.

Cargo.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,9 +15,9 @@ license = "Apache-2.0"
1515

1616
[workspace.dependencies]
1717
toolpath = { version = "0.1.5", path = "crates/toolpath" }
18-
toolpath-convo = { version = "0.4.0", path = "crates/toolpath-convo" }
18+
toolpath-convo = { version = "0.5.0", path = "crates/toolpath-convo" }
1919
toolpath-git = { version = "0.1.3", path = "crates/toolpath-git" }
20-
toolpath-claude = { version = "0.6.1", path = "crates/toolpath-claude", default-features = false }
20+
toolpath-claude = { version = "0.6.2", path = "crates/toolpath-claude", default-features = false }
2121
toolpath-dot = { version = "0.1.2", path = "crates/toolpath-dot" }
2222

2323
serde = { version = "1.0", features = ["derive"] }

crates/toolpath-claude/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "toolpath-claude"
3-
version = "0.6.1"
3+
version = "0.6.2"
44
edition.workspace = true
55
license.workspace = true
66
repository = "https://github.com/empathic/toolpath"

crates/toolpath-claude/README.md

Lines changed: 32 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -136,10 +136,20 @@ follows up with `WatcherEvent::TurnUpdated` when tool results arrive:
136136
use toolpath_convo::{ConversationWatcher, WatcherEvent};
137137
138138
for event in watcher.poll()? {
139-
match event {
140-
WatcherEvent::Turn(turn) => ui.add_turn(*turn),
141-
WatcherEvent::TurnUpdated(turn) => ui.replace_turn(*turn),
142-
WatcherEvent::Progress { .. } => {}
139+
match &event {
140+
WatcherEvent::Turn(turn) => ui.add_turn(turn),
141+
WatcherEvent::TurnUpdated(turn) => ui.replace_turn(turn),
142+
WatcherEvent::Progress { kind, data } => {
143+
match kind.as_str() {
144+
"session_rotated" => ui.notify_rotation(&data["from"], &data["to"]),
145+
_ => {
146+
// Provider-specific progress data under data["claude"]
147+
if let Some(claude) = data.get("claude") {
148+
ui.show_progress(kind, claude);
149+
}
150+
}
151+
}
152+
}
143153
}
144154
}
145155
```
@@ -179,6 +189,24 @@ cache read, cache write) into a single aggregate.
179189
`ConversationView.files_changed` lists all files mutated during the session
180190
(deduplicated, first-touch order), derived from `FileWrite`-categorized tool inputs.
181191

192+
**Provider-specific metadata** — Claude log entries often carry extra fields
193+
(e.g. `subtype`, `data`) that don't map to the common `Turn` schema. These are
194+
forwarded into `Turn.extra["claude"]` so trait-only consumers can access them
195+
without importing Claude-specific types:
196+
197+
```rust,ignore
198+
// State inference from provider metadata
199+
if let Some(claude) = turn.extra.get("claude") {
200+
if claude.get("subtype").and_then(|v| v.as_str()) == Some("init") {
201+
// This is a session initialization entry
202+
}
203+
}
204+
```
205+
206+
For `WatcherEvent::Progress` events, the full entry payload is similarly
207+
available under `data["claude"]` — carrying fields like `data.type`,
208+
`data.hookName`, `data.agentId`, and `data.message`.
209+
182210
See [`toolpath-convo`](https://crates.io/crates/toolpath-convo) for the full trait and type definitions.
183211

184212
## Part of Toolpath

crates/toolpath-claude/src/provider.rs

Lines changed: 84 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,8 @@
55
//! pairs them by `tool_use_id` so consumers get complete `Turn` values
66
//! with `ToolInvocation.result` populated.
77
8+
use std::collections::HashMap;
9+
810
use crate::ClaudeConvo;
911
use crate::types::{Conversation, ConversationEntry, Message, MessageContent, MessageRole};
1012
#[cfg(any(feature = "watcher", test))]
@@ -82,6 +84,17 @@ fn message_to_turn(entry: &ConversationEntry, msg: &Message) -> Turn {
8284

8385
let delegations = extract_delegations(&tool_uses);
8486

87+
let extra = if entry.extra.is_empty() {
88+
HashMap::new()
89+
} else {
90+
let mut map = HashMap::new();
91+
map.insert(
92+
"claude".to_string(),
93+
serde_json::to_value(&entry.extra).unwrap_or_default(),
94+
);
95+
map
96+
};
97+
8598
Turn {
8699
id: entry.uuid.clone(),
87100
parent_id: entry.parent_uuid.clone(),
@@ -95,7 +108,7 @@ fn message_to_turn(entry: &ConversationEntry, msg: &Message) -> Turn {
95108
token_usage,
96109
environment,
97110
delegations,
98-
extra: Default::default(),
111+
extra,
99112
}
100113
}
101114

@@ -280,13 +293,20 @@ fn extract_files_changed(turns: &[Turn]) -> Vec<String> {
280293
fn entry_to_watcher_event(entry: &ConversationEntry) -> WatcherEvent {
281294
match entry_to_turn(entry) {
282295
Some(turn) => WatcherEvent::Turn(Box::new(turn)),
283-
None => WatcherEvent::Progress {
284-
kind: entry.entry_type.clone(),
285-
data: serde_json::json!({
296+
None => {
297+
let mut data = serde_json::json!({
286298
"uuid": entry.uuid,
287299
"timestamp": entry.timestamp,
288-
}),
289-
},
300+
});
301+
if !entry.extra.is_empty() {
302+
data["claude"] =
303+
serde_json::to_value(&entry.extra).unwrap_or_default();
304+
}
305+
WatcherEvent::Progress {
306+
kind: entry.entry_type.clone(),
307+
data,
308+
}
309+
}
290310
}
291311
}
292312

@@ -1119,6 +1139,64 @@ mod tests {
11191139
);
11201140
}
11211141

1142+
// ── Provider-specific extras (Turn.extra["claude"]) ─────────────
1143+
1144+
#[test]
1145+
fn test_turn_extra_populated_from_entry() {
1146+
let entry: ConversationEntry = serde_json::from_str(
1147+
r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","subtype":"init","message":{"role":"user","content":"hello"}}"#,
1148+
)
1149+
.unwrap();
1150+
let turn = to_turn(&entry).unwrap();
1151+
let claude = turn.extra.get("claude").expect("extra[\"claude\"] missing");
1152+
assert_eq!(claude["subtype"], "init");
1153+
}
1154+
1155+
#[test]
1156+
fn test_turn_extra_empty_when_no_extras() {
1157+
let entry: ConversationEntry = serde_json::from_str(
1158+
r#"{"uuid":"u1","type":"user","timestamp":"2024-01-01T00:00:00Z","message":{"role":"user","content":"hello"}}"#,
1159+
)
1160+
.unwrap();
1161+
let turn = to_turn(&entry).unwrap();
1162+
assert!(turn.extra.is_empty());
1163+
}
1164+
1165+
#[test]
1166+
fn test_progress_data_enriched_with_extras() {
1167+
let entry: ConversationEntry = serde_json::from_str(
1168+
r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z","data":{"type":"hook_progress","hookName":"pre-commit"}}"#,
1169+
)
1170+
.unwrap();
1171+
let event = entry_to_watcher_event(&entry);
1172+
match event {
1173+
WatcherEvent::Progress { kind, data } => {
1174+
assert_eq!(kind, "progress");
1175+
assert_eq!(data["uuid"], "u1");
1176+
assert_eq!(data["timestamp"], "2024-01-01T00:00:00Z");
1177+
let claude = &data["claude"];
1178+
assert_eq!(claude["data"]["type"], "hook_progress");
1179+
assert_eq!(claude["data"]["hookName"], "pre-commit");
1180+
}
1181+
other => panic!("Expected Progress, got {:?}", std::mem::discriminant(&other)),
1182+
}
1183+
}
1184+
1185+
#[test]
1186+
fn test_progress_data_no_claude_key_when_no_extras() {
1187+
let entry: ConversationEntry = serde_json::from_str(
1188+
r#"{"uuid":"u1","type":"progress","timestamp":"2024-01-01T00:00:00Z"}"#,
1189+
)
1190+
.unwrap();
1191+
let event = entry_to_watcher_event(&entry);
1192+
match event {
1193+
WatcherEvent::Progress { data, .. } => {
1194+
assert!(data.get("claude").is_none());
1195+
}
1196+
other => panic!("Expected Progress, got {:?}", std::mem::discriminant(&other)),
1197+
}
1198+
}
1199+
11221200
#[test]
11231201
fn test_no_delegations_for_non_task_tools() {
11241202
let (_temp, provider) = setup_provider();

crates/toolpath-convo/Cargo.toml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
[package]
22
name = "toolpath-convo"
3-
version = "0.4.0"
3+
version = "0.5.0"
44
edition.workspace = true
55
license.workspace = true
66
repository = "https://github.com/empathic/toolpath"

crates/toolpath-convo/README.md

Lines changed: 35 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,7 +24,7 @@ Write your conversation analysis once, swap providers without changing a line.
2424
| `TokenUsage` | Input/output/cache token counts |
2525
| `EnvironmentSnapshot` | Working directory and VCS branch/revision at time of a turn |
2626
| `DelegatedWork` | A sub-agent delegation: prompt, nested turns, result |
27-
| `WatcherEvent` | A `Turn` (new), `TurnUpdated` (enriched with tool results), or `Progress` event |
27+
| `WatcherEvent` | A `Turn` (new), `TurnUpdated` (enriched with tool results), or `Progress` event — with `as_turn()`, `as_progress()`, `is_update()`, `turn_id()` helpers for ergonomic dispatch |
2828

2929
**Traits** define how providers expose their data:
3030

@@ -101,6 +101,40 @@ let writes: Vec<_> = turn.tool_uses.iter()
101101
.collect();
102102
```
103103

104+
## Watching
105+
106+
Dispatch on `WatcherEvent` with `match` — three variants, exhaustive:
107+
108+
```rust,ignore
109+
use toolpath_convo::{ConversationWatcher, WatcherEvent};
110+
111+
for event in watcher.poll()? {
112+
match &event {
113+
WatcherEvent::Turn(turn) => ui.add_turn(turn),
114+
WatcherEvent::TurnUpdated(turn) => ui.replace_turn(turn),
115+
WatcherEvent::Progress { kind, data } => ui.show_progress(kind, data),
116+
}
117+
}
118+
```
119+
120+
Convenience methods (`as_turn()`, `as_progress()`, `is_update()`, `turn_id()`)
121+
are available for cases where the distinction between `Turn` and `TurnUpdated`
122+
collapses — e.g. a formatting pipeline that takes a turn + flag:
123+
124+
```rust,ignore
125+
// When Turn/TurnUpdated go through the same path:
126+
if let Some(turn) = event.as_turn() {
127+
format_turn(turn, event.is_update());
128+
}
129+
130+
// Keying/dedup without matching two variants:
131+
if let Some(id) = event.turn_id() {
132+
seen.insert(id);
133+
}
134+
```
135+
136+
Provider-specific metadata lives in `Turn.extra`, namespaced by provider (e.g. `turn.extra["claude"]`). This keeps the common schema clean while giving consumers opt-in access to provider internals.
137+
104138
## Provider implementations
105139

106140
| Provider | Crate |

0 commit comments

Comments
 (0)