From 0d8e00919dbc058601d5fc4f1dc483712408900f Mon Sep 17 00:00:00 2001 From: "Jose I. Paris" Date: Wed, 8 Apr 2026 18:08:22 +0200 Subject: [PATCH] feat(attestation): add missing fields to aicodingsession upstream types Add AI/human attribution fields to CodeChanges, extended FileChange with line counts, attribution, line ranges, and session IDs. Replace untyped Subagents with typed Subagent/SubagentTokens structs. Update JSON schema to document the new properties. Closes #3004 Signed-off-by: Jose I. Paris --- .../ai-coding-session-0.1.schema.json | 128 +++++++++++++++++- .../aicodingsession/aicodingsession.go | 47 +++++-- .../chainloop_ai_coding_session_test.go | 69 ++++++++++ .../ai-coding-session-with-attribution.json | 109 +++++++++++++++ 4 files changed, 341 insertions(+), 12 deletions(-) create mode 100644 pkg/attestation/crafter/materials/testdata/ai-coding-session-with-attribution.json diff --git a/internal/schemavalidators/internal_schemas/aicodingsession/ai-coding-session-0.1.schema.json b/internal/schemavalidators/internal_schemas/aicodingsession/ai-coding-session-0.1.schema.json index 9aacfab58..573744d48 100644 --- a/internal/schemavalidators/internal_schemas/aicodingsession/ai-coding-session-0.1.schema.json +++ b/internal/schemavalidators/internal_schemas/aicodingsession/ai-coding-session-0.1.schema.json @@ -69,7 +69,101 @@ }, "code_changes": { "type": "object", - "description": "Summary of code modifications" + "description": "Summary of code modifications", + "properties": { + "files_modified": { + "type": "integer", + "description": "Number of files modified" + }, + "files_created": { + "type": "integer", + "description": "Number of files created" + }, + "files_deleted": { + "type": "integer", + "description": "Number of files deleted" + }, + "lines_added": { + "type": "integer", + "description": "Total lines added" + }, + "lines_removed": { + "type": "integer", + "description": "Total lines removed" + }, + "ai_lines_added": { + "type": "integer", + "description": "Lines added attributed to AI" + }, + "ai_lines_removed": { + "type": "integer", + "description": "Lines removed attributed to AI" + }, + "human_lines_added": { + "type": "integer", + "description": "Lines added attributed to human" + }, + "human_lines_removed": { + "type": "integer", + "description": "Lines removed attributed to human" + }, + "files": { + "type": "array", + "description": "Per-file change details", + "items": { + "type": "object", + "required": ["path", "status"], + "properties": { + "path": { + "type": "string", + "description": "File path" + }, + "status": { + "type": "string", + "description": "Change status (e.g. modified, created, deleted)" + }, + "lines_added": { + "type": "integer", + "description": "Lines added in this file" + }, + "lines_removed": { + "type": "integer", + "description": "Lines removed in this file" + }, + "attribution": { + "type": "string", + "description": "Change attribution: ai or human" + }, + "line_ranges": { + "type": "array", + "description": "Contiguous line ranges", + "items": { + "type": "object", + "required": ["start", "end"], + "properties": { + "start": { + "type": "integer", + "description": "Start line number" + }, + "end": { + "type": "integer", + "description": "End line number" + } + }, + "additionalProperties": false + } + }, + "session_ids": { + "type": "array", + "description": "Session IDs associated with changes in this file", + "items": { + "type": "string" + } + } + } + } + } + } }, "model": { "type": "object", @@ -89,9 +183,37 @@ }, "subagents": { "type": "array", - "description": "Subagent information (open schema)", + "description": "Subagent information", "items": { - "type": "object" + "type": "object", + "properties": { + "id": { + "type": "string", + "description": "Unique subagent identifier" + }, + "type": { + "type": "string", + "description": "Subagent type" + }, + "description": { + "type": "string", + "description": "Subagent description" + }, + "tokens": { + "type": "object", + "description": "Token usage for this subagent", + "properties": { + "input": { + "type": "integer", + "description": "Input tokens consumed" + }, + "output": { + "type": "integer", + "description": "Output tokens produced" + } + } + } + } } }, "raw_session": { diff --git a/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go b/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go index f614668d7..d3ed1de69 100644 --- a/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go +++ b/pkg/attestation/crafter/materials/aicodingsession/aicodingsession.go @@ -50,20 +50,35 @@ type GitContext struct { CommitCount int `json:"commit_count,omitempty"` } +// LineRange represents a contiguous range of lines. +type LineRange struct { + Start int `json:"start"` + End int `json:"end"` +} + // FileChange represents a single file modification in the session. type FileChange struct { - Path string `json:"path"` - Status string `json:"status"` + Path string `json:"path"` + Status string `json:"status"` + LinesAdded int `json:"lines_added,omitempty"` + LinesRemoved int `json:"lines_removed,omitempty"` + Attribution string `json:"attribution,omitempty"` + LineRanges []LineRange `json:"line_ranges,omitempty"` + SessionIDs []string `json:"session_ids,omitempty"` } // CodeChanges summarizes code modifications made during the session. type CodeChanges struct { - FilesModified int `json:"files_modified,omitempty"` - FilesCreated int `json:"files_created,omitempty"` - FilesDeleted int `json:"files_deleted,omitempty"` - LinesAdded int `json:"lines_added,omitempty"` - LinesRemoved int `json:"lines_removed,omitempty"` - Files []FileChange `json:"files,omitempty"` + FilesModified int `json:"files_modified,omitempty"` + FilesCreated int `json:"files_created,omitempty"` + FilesDeleted int `json:"files_deleted,omitempty"` + LinesAdded int `json:"lines_added,omitempty"` + LinesRemoved int `json:"lines_removed,omitempty"` + AILinesAdded int `json:"ai_lines_added,omitempty"` + AILinesRemoved int `json:"ai_lines_removed,omitempty"` + HumanLinesAdded int `json:"human_lines_added,omitempty"` + HumanLinesRemoved int `json:"human_lines_removed,omitempty"` + Files []FileChange `json:"files,omitempty"` } // Model holds information about the AI models used in the session. @@ -95,6 +110,20 @@ type ToolsUsed struct { TotalInvocations int `json:"total_invocations,omitempty"` } +// SubagentTokens holds token usage for a subagent. +type SubagentTokens struct { + Input int `json:"input"` + Output int `json:"output"` +} + +// Subagent describes a spawned subagent within the session. +type Subagent struct { + ID string `json:"id"` + Type string `json:"type"` + Description string `json:"description"` + Tokens SubagentTokens `json:"tokens"` +} + // Conversation holds message count statistics. type Conversation struct { TotalMessages int `json:"total_messages,omitempty"` @@ -113,7 +142,7 @@ type Data struct { Usage *Usage `json:"usage,omitempty"` ToolsUsed *ToolsUsed `json:"tools_used,omitempty"` Conversation *Conversation `json:"conversation,omitempty"` - Subagents []json.RawMessage `json:"subagents,omitempty"` + Subagents []Subagent `json:"subagents,omitempty"` RawSession map[string][]json.RawMessage `json:"raw_session,omitempty"` Warnings []string `json:"warnings,omitempty"` } diff --git a/pkg/attestation/crafter/materials/chainloop_ai_coding_session_test.go b/pkg/attestation/crafter/materials/chainloop_ai_coding_session_test.go index 144086d49..92eb9386c 100644 --- a/pkg/attestation/crafter/materials/chainloop_ai_coding_session_test.go +++ b/pkg/attestation/crafter/materials/chainloop_ai_coding_session_test.go @@ -226,6 +226,68 @@ func TestChainloopAICodingSessionCrafter_RejectsExtraFields(t *testing.T) { assert.Contains(t, err.Error(), "AI coding session validation failed") } +func TestChainloopAICodingSessionCrafter_RealWorldEvidence(t *testing.T) { + // Load real-world evidence that uses the new attribution, line range, and subagent fields. + raw, err := os.ReadFile("./testdata/ai-coding-session-with-attribution.json") + require.NoError(t, err) + + var evidence aicodingsession.Evidence + require.NoError(t, json.Unmarshal(raw, &evidence)) + + data := evidence.Data + + // Top-level identifiers + assert.Equal(t, aicodingsession.EvidenceID, evidence.ID) + assert.Equal(t, aicodingsession.EvidenceSchemaURL, evidence.Schema) + assert.Equal(t, "v1", data.SchemaVersion) + + // Agent + assert.Equal(t, "claude-code", data.Agent.Name) + assert.Equal(t, "2.1.92", data.Agent.Version) + + // Session + assert.Equal(t, "1dea3f43-4c8f-4625-8732-349613261162", data.Session.ID) + assert.Equal(t, 14, data.Session.DurationSeconds) + + // Code changes — AI/human attribution + require.NotNil(t, data.CodeChanges) + assert.Equal(t, 11, data.CodeChanges.LinesAdded) + assert.Equal(t, 8, data.CodeChanges.AILinesAdded) + assert.Equal(t, 0, data.CodeChanges.AILinesRemoved) + assert.Equal(t, 3, data.CodeChanges.HumanLinesAdded) + assert.Equal(t, 0, data.CodeChanges.HumanLinesRemoved) + + // File-level fields + require.Len(t, data.CodeChanges.Files, 2) + + goMod := data.CodeChanges.Files[0] + assert.Equal(t, "go.mod", goMod.Path) + assert.Equal(t, "human", goMod.Attribution) + assert.Equal(t, 3, goMod.LinesAdded) + + mainGo := data.CodeChanges.Files[1] + assert.Equal(t, "main.go", mainGo.Path) + assert.Equal(t, "ai", mainGo.Attribution) + assert.Equal(t, 8, mainGo.LinesAdded) + require.Len(t, mainGo.LineRanges, 1) + assert.Equal(t, 6, mainGo.LineRanges[0].Start) + assert.Equal(t, 6, mainGo.LineRanges[0].End) + require.Len(t, mainGo.SessionIDs, 1) + assert.Equal(t, "1dea3f43-4c8f-4625-8732-349613261162", mainGo.SessionIDs[0]) + + // Usage + require.NotNil(t, data.Usage) + assert.InDelta(t, 0.1917, data.Usage.EstimatedCostUSD, 0.0001) + + // Schema validation should pass + dataBytes, err := json.Marshal(data) + require.NoError(t, err) + + var rawData any + require.NoError(t, json.Unmarshal(dataBytes, &rawData)) + require.NoError(t, schemavalidators.ValidateAICodingSession(rawData, schemavalidators.AICodingSessionVersion0_1)) +} + func TestChainloopAICodingSessionCrafter_Annotations(t *testing.T) { testCases := []struct { name string @@ -241,6 +303,13 @@ func TestChainloopAICodingSessionCrafter_Annotations(t *testing.T) { expectedModel: "claude-opus-4-6", modelPresent: true, }, + { + name: "session with AI attribution fields", + filePath: "./testdata/ai-coding-session-with-attribution.json", + expectedAgentName: "claude-code", + expectedModel: "claude-opus-4-6", + modelPresent: true, + }, { name: "minimal session without model", filePath: "./testdata/ai-coding-session-minimal.json", diff --git a/pkg/attestation/crafter/materials/testdata/ai-coding-session-with-attribution.json b/pkg/attestation/crafter/materials/testdata/ai-coding-session-with-attribution.json new file mode 100644 index 000000000..7da6d9544 --- /dev/null +++ b/pkg/attestation/crafter/materials/testdata/ai-coding-session-with-attribution.json @@ -0,0 +1,109 @@ +{ + "schema": "https://schemas.chainloop.dev/aicodingsession/0.1/ai-coding-session.schema.json", + "chainloop.material.evidence.id": "CHAINLOOP_AI_CODING_SESSION", + "data": { + "schema_version": "v1", + "agent": { + "name": "claude-code", + "version": "2.1.92" + }, + "session": { + "id": "1dea3f43-4c8f-4625-8732-349613261162", + "slug": "keen-juggling-hickey", + "started_at": "2026-04-06T17:59:31.566Z", + "ended_at": "2026-04-06T17:59:46.098Z", + "duration_seconds": 14 + }, + "git_context": { + "repository": "git@github.com:jiparis/workflow-tests.git", + "branch": "add-credits-comment", + "work_dir": "/Users/jiparis/projects/playground/workflow-tests", + "commit_start": "f9ab7b49bd2f94a8925d55821792308f6ec87a57", + "commit_end": "f9ab7b49bd2f94a8925d55821792308f6ec87a57", + "commits": [ + "f9ab7b49bd2f94a8925d55821792308f6ec87a57" + ], + "commit_count": 1 + }, + "code_changes": { + "files_modified": 0, + "files_created": 2, + "files_deleted": 0, + "lines_added": 11, + "lines_removed": 0, + "ai_lines_added": 8, + "ai_lines_removed": 0, + "human_lines_added": 3, + "human_lines_removed": 0, + "files": [ + { + "path": "go.mod", + "status": "created", + "lines_added": 3, + "lines_removed": 0, + "attribution": "human" + }, + { + "path": "main.go", + "status": "created", + "lines_added": 8, + "lines_removed": 0, + "attribution": "ai", + "line_ranges": [ + { + "start": 6, + "end": 6 + } + ], + "session_ids": [ + "1dea3f43-4c8f-4625-8732-349613261162" + ] + } + ] + }, + "model": { + "primary": "claude-opus-4-6", + "provider": "anthropic", + "models_used": [ + "claude-opus-4-6" + ] + }, + "usage": { + "input_tokens": 8, + "output_tokens": 294, + "total_tokens": 302, + "cache_read_input_tokens": 91602, + "cache_creation_input_tokens": 22159, + "estimated_cost_usd": 0.1917 + }, + "tools_used": { + "summary": [ + { + "tool_name": "Read", + "invocation_count": 1 + }, + { + "tool_name": "Edit", + "invocation_count": 1 + } + ], + "total_invocations": 2 + }, + "conversation": { + "total_messages": 7, + "user_messages": 3, + "assistant_messages": 4 + }, + "subagents": [], + "raw_session": { + "main": [ + { + "type": "permission-mode", + "permissionMode": "default", + "sessionId": "1dea3f43-4c8f-4625-8732-349613261162" + } + ] + }, + "warnings": [] + } +}