Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
31 changes: 31 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,37 @@

All notable changes to the Toolpath workspace are documented here.

## Harness-generated turns take the harness actor — 2026-08-06

A harness sometimes emits an assistant message itself — an API error, a
rate-limit notice, a timeout — with no model call behind it. Claude Code
marks these with a placeholder in `message.model`, and `derive_path`
took that placeholder at face value: the step landed on an `agent:`
actor naming a string that is not a model, and `meta.actors` described
the harness as if it were one. The actor convention never meant that —
`agent:<model>` is "a model reply, named by the recorded model", and a
placeholder is neither a model nor a name for one.

- **`toolpath-convo`** (0.12.0): `Turn` gains a provider-agnostic
`harness_generated` flag (serde-optional, omitted when false, so the
wire format stays byte-compatible with producers that predate it).
`derive_path` attributes such turns to the harness actor
`tool:<provider>` — the same actor system and other roles already
take — instead of to an `agent:` actor. The turn's `role` stays
`assistant`, so the message keeps its place in the transcript; only
the attribution changes. `agent:unknown` is unaffected: "no model
recorded" and "no model involved" stay distinguishable. Minor bump —
a new public field on `Turn` breaks struct-literal construction.
- **`toolpath-claude`** (0.12.3): sets the flag from Claude Code's
placeholder model and leaves `Turn.model` empty, so the placeholder
string no longer reaches the IR. The placeholder is one harness's
format detail and stays owned by the crate that reads that format;
any provider can set the flag from whatever signal its own format
gives it, without touching shared code.

No schema change: this is a deriver fix, valid under the existing
`agent-coding-session` kind.

## Projected Claude sessions are resumable again — 2026-07-30

Two fixes found by live-resuming a projected session against the real
Expand Down
4 changes: 2 additions & 2 deletions Cargo.lock

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

4 changes: 2 additions & 2 deletions Cargo.toml
Original file line number Diff line number Diff line change
Expand Up @@ -25,9 +25,9 @@ license = "Apache-2.0"

[workspace.dependencies]
toolpath = { version = "0.7.0", path = "crates/toolpath" }
toolpath-convo = { version = "0.11.1", path = "crates/toolpath-convo" }
toolpath-convo = { version = "0.12.0", path = "crates/toolpath-convo" }
toolpath-git = { version = "0.6.0", path = "crates/toolpath-git" }
toolpath-claude = { version = "0.12.2", path = "crates/toolpath-claude", default-features = false }
toolpath-claude = { version = "0.12.3", path = "crates/toolpath-claude", default-features = false }
toolpath-gemini = { version = "0.6.1", path = "crates/toolpath-gemini", default-features = false }
toolpath-codex = { version = "0.6.1", path = "crates/toolpath-codex" }
toolpath-copilot = { version = "0.1.0", path = "crates/toolpath-copilot" }
Expand Down
2 changes: 1 addition & 1 deletion crates/toolpath-claude/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "toolpath-claude"
version = "0.12.2"
version = "0.12.3"
edition.workspace = true
license.workspace = true
repository = "https://github.com/empathic/toolpath"
Expand Down
2 changes: 2 additions & 0 deletions crates/toolpath-claude/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -1042,6 +1042,7 @@ mod tests {
thinking: None,
tool_uses: vec![],
model: None,
harness_generated: false,
stop_reason: None,
token_usage: None,
attributed_token_usage: None,
Expand All @@ -1062,6 +1063,7 @@ mod tests {
thinking: None,
tool_uses: vec![],
model: None,
harness_generated: false,
stop_reason: None,
token_usage: None,
attributed_token_usage: None,
Expand Down
43 changes: 42 additions & 1 deletion crates/toolpath-claude/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,12 @@ use toolpath_convo::{

// ── Conversion helpers ───────────────────────────────────────────────

/// Claude Code's placeholder in `message.model` on assistant messages the
/// harness generated itself — API errors, rate-limit notices, timeouts —
/// where no model ran. It is a marker, not a model identifier, so it maps
/// to [`Turn::harness_generated`] and never into `Turn::model`.
const SYNTHETIC_MODEL: &str = "<synthetic>";

fn claude_role_to_role(role: &MessageRole) -> Role {
match role {
MessageRole::User => Role::User,
Expand Down Expand Up @@ -120,6 +126,8 @@ fn message_to_turn(entry: &ConversationEntry, msg: &Message) -> Turn {

let delegations = extract_delegations(&tool_uses);

let harness_generated = msg.model.as_deref() == Some(SYNTHETIC_MODEL);

Turn {
id: entry.uuid.clone(),
parent_id: entry.parent_uuid.clone(),
Expand All @@ -133,7 +141,12 @@ fn message_to_turn(entry: &ConversationEntry, msg: &Message) -> Turn {
text,
thinking,
tool_uses,
model: msg.model.clone(),
model: if harness_generated {
None
} else {
msg.model.clone()
},
harness_generated,
stop_reason: msg.stop_reason.clone(),
token_usage,
attributed_token_usage: None,
Expand Down Expand Up @@ -861,6 +874,7 @@ mod tests {
thinking: None,
tool_uses: vec![],
model: None,
harness_generated: false,
stop_reason: None,
token_usage: None,
attributed_token_usage: None,
Expand Down Expand Up @@ -1253,6 +1267,32 @@ mod tests {
assert_eq!(turn.role, Role::User);
}

#[test]
fn test_to_turn_maps_placeholder_model_to_harness_generated() {
// Claude Code's placeholder marks a message the harness produced
// itself; it is not a model identifier, so it becomes the flag and
// leaves `model` empty.
let entry: ConversationEntry = serde_json::from_str(&format!(
r#"{{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{{"role":"assistant","model":"{SYNTHETIC_MODEL}","content":[{{"type":"text","text":"API Error: Connection reset"}}]}}}}"#
))
.unwrap();
let turn = to_turn(&entry).unwrap();
assert!(turn.harness_generated);
assert_eq!(turn.model, None);
assert_eq!(turn.role, Role::Assistant);
}

#[test]
fn test_to_turn_keeps_a_real_model_and_leaves_the_flag_unset() {
let entry: ConversationEntry = serde_json::from_str(
r#"{"uuid":"u1","type":"assistant","timestamp":"2024-01-01T00:00:00Z","message":{"role":"assistant","model":"claude-opus-4-8","content":[{"type":"text","text":"on it"}]}}"#,
)
.unwrap();
let turn = to_turn(&entry).unwrap();
assert!(!turn.harness_generated);
assert_eq!(turn.model.as_deref(), Some("claude-opus-4-8"));
}

#[test]
fn test_to_turn_without_message() {
let entry: ConversationEntry = serde_json::from_str(
Expand Down Expand Up @@ -1455,6 +1495,7 @@ mod tests {
},
],
model: None,
harness_generated: false,
stop_reason: None,
token_usage: None,
attributed_token_usage: None,
Expand Down
2 changes: 2 additions & 0 deletions crates/toolpath-codex/src/project.rs
Original file line number Diff line number Diff line change
Expand Up @@ -720,6 +720,7 @@ mod tests {
thinking: None,
tool_uses: vec![],
model: None,
harness_generated: false,
stop_reason: None,
token_usage: None,
attributed_token_usage: None,
Expand All @@ -740,6 +741,7 @@ mod tests {
thinking: None,
tool_uses: vec![],
model: Some("gpt-5.4".into()),
harness_generated: false,
stop_reason: Some("stop".into()),
token_usage: Some(TokenUsage {
input_tokens: Some(100),
Expand Down
2 changes: 2 additions & 0 deletions crates/toolpath-codex/src/provider.rs
Original file line number Diff line number Diff line change
Expand Up @@ -827,6 +827,7 @@ fn message_to_turn(
} else {
None
},
harness_generated: false,
stop_reason: None,
token_usage: None,
attributed_token_usage: None,
Expand All @@ -851,6 +852,7 @@ fn synthetic_assistant_turn(
thinking: None,
tool_uses: Vec::new(),
model: model.map(str::to_string),
harness_generated: false,
stop_reason: None,
token_usage: None,
attributed_token_usage: None,
Expand Down
2 changes: 1 addition & 1 deletion crates/toolpath-convo/Cargo.toml
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
[package]
name = "toolpath-convo"
version = "0.11.1"
version = "0.12.0"
edition.workspace = true
license.workspace = true
repository = "https://github.com/empathic/toolpath"
Expand Down
71 changes: 71 additions & 0 deletions crates/toolpath-convo/src/derive.rs
Original file line number Diff line number Diff line change
Expand Up @@ -519,6 +519,11 @@ fn serde_value_eq(a: &Step, b: &Step) -> bool {
fn actor_for_turn(turn: &Turn, provider: &str) -> String {
match &turn.role {
Role::User => "human:user".to_string(),
// A harness-generated assistant message is the tool speaking, not a
// model, so it takes the same `tool:{provider}` actor as the System
// and Other roles. `agent:unknown` stays reserved for "no model
// recorded", which is a different state from "no model involved".
Role::Assistant if turn.harness_generated => format!("tool:{}", provider),
Role::Assistant => {
let model = turn.model.as_deref().unwrap_or("unknown");
format!("agent:{}", model)
Expand Down Expand Up @@ -728,6 +733,7 @@ mod tests {
thinking: None,
tool_uses: vec![],
model: None,
harness_generated: false,
stop_reason: None,
token_usage: None,
attributed_token_usage: None,
Expand Down Expand Up @@ -949,11 +955,76 @@ mod tests {
#[test]
fn test_assistant_without_model() {
let turn = base_turn("t1", Role::Assistant);
assert!(!turn.harness_generated);
assert_eq!(turn.model, None);
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());
// "No model recorded" is its own outcome, distinct from a
// harness-generated turn.
assert_eq!(path.steps[0].step.actor, "agent:unknown");
}

#[test]
fn test_harness_generated_assistant_is_attributed_to_the_harness() {
let mut turn = base_turn("t1", Role::Assistant);
turn.harness_generated = true;
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());

// The harness generated the message; no model produced it.
assert_eq!(path.steps[0].step.actor, "tool:pi");
// Only attribution changes — the message keeps the assistant slot.
assert_eq!(
conv_change(&path.steps[0]).extra["role"],
serde_json::json!("assistant")
);

let actors = path.meta.as_ref().unwrap().actors.as_ref().unwrap();
let actor = &actors["tool:pi"];
assert_eq!(actor.provider.as_deref(), Some("pi"));
assert_eq!(actor.model, None);
}

#[test]
fn test_harness_generated_turn_ignores_a_stray_model() {
// The flag is the authority: a harness-generated turn takes the
// harness actor even if the source recorded a model anyway.
let mut turn = base_turn("t1", Role::Assistant);
turn.harness_generated = true;
turn.model = Some("claude-opus-4-8".into());
let view = view_with(vec![turn]);
let path = derive_path(&view, &DeriveConfig::default());

assert_eq!(path.steps[0].step.actor, "tool:pi");
}

#[test]
fn test_harness_generated_flag_is_omitted_when_false() {
// skip_serializing_if keeps the Turn wire format byte-compatible
// with producers that predate the flag.
let turn = base_turn("t1", Role::Assistant);
let json = serde_json::to_string(&turn).unwrap();
assert!(
!json.contains("harness_generated"),
"false must be omitted, got: {json}"
);
let back: Turn = serde_json::from_str(&json).unwrap();
assert!(!back.harness_generated);
}

#[test]
fn test_harness_generated_flag_round_trips_when_true() {
let mut turn = base_turn("t1", Role::Assistant);
turn.harness_generated = true;
let json = serde_json::to_string(&turn).unwrap();
assert!(
json.contains("\"harness_generated\":true"),
"true must be written, got: {json}"
);
let back: Turn = serde_json::from_str(&json).unwrap();
assert!(back.harness_generated);
}

#[test]
fn test_system_role() {
let turn = base_turn("t1", Role::System);
Expand Down
1 change: 1 addition & 0 deletions crates/toolpath-convo/src/extract.rs
Original file line number Diff line number Diff line change
Expand Up @@ -325,6 +325,7 @@ fn build_turn(step: &Step, extra: &HashMap<String, serde_json::Value>) -> Turn {
thinking,
tool_uses,
model,
harness_generated: false,
stop_reason,
token_usage,
attributed_token_usage,
Expand Down
12 changes: 12 additions & 0 deletions crates/toolpath-convo/src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -281,6 +281,14 @@ pub struct Turn {
/// Model identifier (e.g. "claude-opus-4-6", "gpt-4o").
pub model: Option<String>,

/// The harness produced this turn itself, with no model call —
/// injected errors, limit notices, timeouts. Such turns are
/// attributed to the harness actor rather than to a model, and their
/// [`Turn::model`] is ignored. Distinct from a turn whose source simply
/// records no model: "no model involved" is not "no model recorded".
#[serde(default, skip_serializing_if = "std::ops::Not::not")]
pub harness_generated: bool,

/// Why the turn ended (e.g. "end_turn", "tool_use", "max_tokens").
pub stop_reason: Option<String>,

Expand Down Expand Up @@ -584,6 +592,7 @@ mod tests {
thinking: None,
tool_uses: vec![],
model: None,
harness_generated: false,
stop_reason: None,
token_usage: None,
attributed_token_usage: None,
Expand All @@ -610,6 +619,7 @@ mod tests {
category: Some(ToolCategory::FileRead),
}],
model: Some("claude-opus-4-6".into()),
harness_generated: false,
stop_reason: Some("end_turn".into()),
token_usage: Some(TokenUsage {
input_tokens: Some(100),
Expand All @@ -633,6 +643,7 @@ mod tests {
thinking: None,
tool_uses: vec![],
model: None,
harness_generated: false,
stop_reason: None,
token_usage: None,
attributed_token_usage: None,
Expand Down Expand Up @@ -973,6 +984,7 @@ mod tests {
thinking: None,
tool_uses: vec![],
model: None,
harness_generated: false,
stop_reason: None,
token_usage: None,
attributed_token_usage: None,
Expand Down
Loading