From a9eca7e8f42f3ef3f7aa225bd5dc36bef03d0880 Mon Sep 17 00:00:00 2001 From: Yang Liu Date: Sun, 31 May 2026 13:41:22 +1200 Subject: [PATCH] fix(parser): compat with Claude Code v2.1.154 Dynamic Workflow entries v2.1.154 ships dynamic workflows, which writes new JSONL entry types (workflow-start, workflow-progress, workflow-complete, workflow-cancelled, workflow-error) and workflow state fields (workflowId, workflowName, workflowRunUrl, workflowStatus) to session files. - Add the five workflow lifecycle types to NOISE_ENTRY_TYPES so they are silently discarded rather than reaching the role-based fallback path - Add workflowId, workflowName, workflowRunUrl, workflowStatus fields to Entry so they are captured without triggering deny_unknown_fields panics - Add tests covering: all five types parsed and discarded, workflow fields round-tripped, Workflow tool_use block extracted from assistant messages, and unknown future workflow fields tolerated by the deserializer - Update specs/01-parser-pipeline.md with new fields and compat table row Fixes #115 --- specs/01-parser-pipeline.md | 17 ++++-- src-tauri/src/parser/classify.rs | 89 +++++++++++++++++++++++++++ src-tauri/src/parser/entry.rs | 101 +++++++++++++++++++++++++++++++ 3 files changed, 201 insertions(+), 6 deletions(-) diff --git a/specs/01-parser-pipeline.md b/specs/01-parser-pipeline.md index 8b51ab8..e337e81 100644 --- a/specs/01-parser-pipeline.md +++ b/specs/01-parser-pipeline.md @@ -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 @@ -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` | --- diff --git a/src-tauri/src/parser/classify.rs b/src-tauri/src/parser/classify.rs index f3f8c67..76cd4a5 100644 --- a/src-tauri/src/parser/classify.rs +++ b/src-tauri/src/parser/classify.rs @@ -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] = &["", ""]; @@ -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] diff --git a/src-tauri/src/parser/entry.rs b/src-tauri/src/parser/entry.rs index 54152b1..c997699 100644 --- a/src-tauri/src/parser/entry.rs +++ b/src-tauri/src/parser/entry.rs @@ -118,6 +118,17 @@ pub struct Entry { pub background_tasks: Option, #[serde(default, rename = "session_crons")] pub session_crons: Option, + // 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)] @@ -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+.