Skip to content
Merged
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
17 changes: 11 additions & 6 deletions specs/01-parser-pipeline.md
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,10 @@ Each JSONL line is decoded into an `Entry` struct that mirrors the raw Claude Co
| `tool_use_result` | JSON object for tool results |
| `background_tasks` | v2.1.145+: running background task descriptors (Stop/SubagentStop hooks) |
| `session_crons` | v2.1.145+: registered session cron jobs (Stop/SubagentStop hooks) |
| `workflowId` | v2.1.154+: workflow identifier on lifecycle entries |
| `workflowName` | v2.1.154+: workflow name on lifecycle entries |
| `workflowRunUrl` | v2.1.154+: workflow run URL on lifecycle entries |
| `workflowStatus` | v2.1.154+: workflow run status on lifecycle entries |

```mermaid
classDiagram
Expand Down Expand Up @@ -125,12 +129,13 @@ flowchart TD

### Version-Compatibility Normalisations

| Issue | Version | Fix |
| ------------------------------------ | ------------ | ------------------------------------------------ |
| Tool inputs JSON-encoded as strings | pre-v2.1.92 | Deserialise inner string → object |
| Fork reference in `forkedFrom` field | pre-v2.1.118 | Map to synthetic `fork-context-ref` |
| Hook payload in content text | all | Regex extraction of teammate ID, color, protocol |
| Large outputs written to disk | v2.1.89+ | `RE_PERSISTED_OUTPUT_PATH` → file read |
| Issue | Version | Fix |
| ------------------------------------ | ------------ | -------------------------------------------------------------- |
| Tool inputs JSON-encoded as strings | pre-v2.1.92 | Deserialise inner string → object |
| Fork reference in `forkedFrom` field | pre-v2.1.118 | Map to synthetic `fork-context-ref` |
| Hook payload in content text | all | Regex extraction of teammate ID, color, protocol |
| Large outputs written to disk | v2.1.89+ | `RE_PERSISTED_OUTPUT_PATH` → file read |
| Dynamic Workflow lifecycle types | v2.1.154+ | Add to `NOISE_ENTRY_TYPES`; capture workflow fields on `Entry` |

---

Expand Down
89 changes: 89 additions & 0 deletions src-tauri/src/parser/classify.rs
Original file line number Diff line number Diff line change
Expand Up @@ -130,6 +130,13 @@ const NOISE_ENTRY_TYPES: &[&str] = &[
// duplicating the full parent conversation. The pointer has no message content — drop it
// silently so it never appears in the conversation display.
"fork-context-ref",
// v2.1.154+: Dynamic Workflow lifecycle entries carry workflow state but no displayable
// message content. Drop silently so they never appear in the conversation transcript.
"workflow-start",
"workflow-progress",
"workflow-complete",
"workflow-cancelled",
"workflow-error",
];

const HARD_NOISE_TAGS: &[&str] = &["<local-command-caveat>", "<system-reminder>"];
Expand Down Expand Up @@ -1741,6 +1748,88 @@ mod tests {
);
}

// --- Issue #115: v2.1.154+ Dynamic Workflow entry types ---

#[test]
fn classify_drops_all_workflow_lifecycle_entry_types_as_noise() {
// v2.1.154+: workflow-start, workflow-progress, workflow-complete, workflow-cancelled,
// and workflow-error are structural metadata with no displayable content; all must be
// silently dropped from the transcript.
for wf_type in &[
"workflow-start",
"workflow-progress",
"workflow-complete",
"workflow-cancelled",
"workflow-error",
] {
let e = Entry {
entry_type: wf_type.to_string(),
uuid: format!("uuid-{wf_type}"),
timestamp: "2026-05-28T10:00:00Z".to_string(),
..Default::default()
};
assert!(
classify(e).is_none(),
"workflow entry type={wf_type} must be silently dropped"
);
}
}

#[test]
fn classify_workflow_tool_use_in_assistant_message_is_extracted() {
// v2.1.154+: the Workflow tool appears as a tool_use block in assistant messages.
// extract_assistant_details must extract it without filtering by tool name.
let content = json!([{
"type": "tool_use",
"id": "toolu_workflow_001",
"name": "Workflow",
"input": {
"workflowName": "my-workflow",
"description": "Run background agents"
}
}]);
let mut e = make_entry("assistant", Some(content));
e.message.model = "claude-sonnet-4-20250514".to_string();
e.message.stop_reason = Some("tool_use".to_string());
match classify(e) {
Some(ClassifiedMsg::AI(ai)) => {
assert_eq!(ai.tool_calls.len(), 1);
assert_eq!(ai.tool_calls[0].name, "Workflow");
let block = ai
.blocks
.iter()
.find(|b| b.block_type == "tool_use")
.expect("must have tool_use block");
assert_eq!(block.tool_name, "Workflow");
let input = block.tool_input.as_ref().expect("input must be captured");
assert_eq!(
input.get("workflowName").and_then(|v| v.as_str()),
Some("my-workflow")
);
}
other => panic!("Expected AI with Workflow tool_use, got {:?}", other),
}
}

#[test]
fn classify_workflow_start_with_data_fields_is_still_dropped() {
// Even a workflow-start entry that carries workflowId / workflowName / workflowStatus
// fields must be silently dropped — it is structural metadata, not transcript content.
let e = Entry {
entry_type: "workflow-start".to_string(),
uuid: "uuid-wf-start-data".to_string(),
timestamp: "2026-05-28T10:00:00Z".to_string(),
workflow_id: "wf-123".to_string(),
workflow_name: "my-workflow".to_string(),
workflow_status: "running".to_string(),
..Default::default()
};
assert!(
classify(e).is_none(),
"workflow-start with data fields must still be dropped"
);
}

// --- Issue #37: document content block is recognised as user content ---

#[test]
Expand Down
101 changes: 101 additions & 0 deletions src-tauri/src/parser/entry.rs
Original file line number Diff line number Diff line change
Expand Up @@ -118,6 +118,17 @@ pub struct Entry {
pub background_tasks: Option<Value>,
#[serde(default, rename = "session_crons")]
pub session_crons: Option<Value>,
// Present in workflow lifecycle entries (v2.1.154+). Claude Code's dynamic workflow
// system writes workflow-start, workflow-progress, workflow-complete, workflow-cancelled,
// and workflow-error entries carrying these fields.
#[serde(default, rename = "workflowId")]
pub workflow_id: String,
#[serde(default, rename = "workflowName")]
pub workflow_name: String,
#[serde(default, rename = "workflowRunUrl")]
pub workflow_run_url: String,
#[serde(default, rename = "workflowStatus")]
pub workflow_status: String,
}

#[derive(Debug, Deserialize, Default)]
Expand Down Expand Up @@ -689,6 +700,96 @@ mod tests {
);
}

// --- Issue #115: v2.1.154+ Dynamic Workflow fields ---

#[test]
fn parse_entry_captures_workflow_fields_v2_1_154() {
// v2.1.154+: workflow lifecycle entries carry workflowId, workflowName,
// workflowRunUrl, and workflowStatus at the top level.
let line = json!({
"type": "workflow-start",
"uuid": "wf-start-uuid-001",
"timestamp": "2026-05-28T10:00:00Z",
"workflowId": "wf-abc-123",
"workflowName": "my-workflow",
"workflowRunUrl": "https://example.com/workflows/wf-abc-123",
"workflowStatus": "running"
});
let bytes = serde_json::to_vec(&line).unwrap();
let entry = parse_entry(&bytes).expect("must parse workflow-start entry");
assert_eq!(entry.entry_type, "workflow-start");
assert_eq!(entry.workflow_id, "wf-abc-123");
assert_eq!(entry.workflow_name, "my-workflow");
assert_eq!(
entry.workflow_run_url,
"https://example.com/workflows/wf-abc-123"
);
assert_eq!(entry.workflow_status, "running");
}

#[test]
fn parse_entry_workflow_fields_default_to_empty_when_absent() {
// Regular entries from before v2.1.154 have no workflow fields — must default to "".
let line = json!({
"type": "user",
"uuid": "regular-uuid-no-wf",
"timestamp": "2026-05-28T10:00:00Z",
"message": {"role": "user", "content": "Hello"}
});
let bytes = serde_json::to_vec(&line).unwrap();
let entry = parse_entry(&bytes).expect("must parse regular entry");
assert_eq!(entry.workflow_id, "");
assert_eq!(entry.workflow_name, "");
assert_eq!(entry.workflow_run_url, "");
assert_eq!(entry.workflow_status, "");
}

#[test]
fn parse_entry_workflow_entry_with_unknown_fields_succeeds() {
// Workflow entries may carry additional fields not yet known. The parser must
// not reject them — no #[serde(deny_unknown_fields)] is set on Entry.
let line = json!({
"type": "workflow-progress",
"uuid": "wf-progress-uuid-001",
"timestamp": "2026-05-28T10:01:00Z",
"workflowId": "wf-xyz-999",
"workflowName": "my-workflow",
"workflowStatus": "running",
"futureWorkflowField": "some-value",
"nestedWorkflowData": {"agentCount": 10, "completedAgents": 3}
});
let bytes = serde_json::to_vec(&line).unwrap();
let entry =
parse_entry(&bytes).expect("must parse workflow-progress despite unknown fields");
assert_eq!(entry.entry_type, "workflow-progress");
assert_eq!(entry.workflow_id, "wf-xyz-999");
}

#[test]
fn parse_entry_all_workflow_lifecycle_types_succeed() {
// All five workflow lifecycle types must parse without panicking or returning None.
for wf_type in &[
"workflow-start",
"workflow-progress",
"workflow-complete",
"workflow-cancelled",
"workflow-error",
] {
let line = json!({
"type": wf_type,
"uuid": format!("uuid-{}", wf_type),
"timestamp": "2026-05-28T10:00:00Z",
"workflowId": "wf-123",
"workflowName": "test-workflow",
"workflowStatus": "running"
});
let bytes = serde_json::to_vec(&line).unwrap();
let entry = parse_entry(&bytes)
.unwrap_or_else(|| panic!("must parse {wf_type} entry without panicking"));
assert_eq!(entry.entry_type, *wf_type);
}
}

#[test]
fn parse_entry_subagent_stop_with_background_tasks_and_session_crons() {
// SubagentStop hook input also gains these fields in v2.1.145+.
Expand Down
Loading