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
2 changes: 1 addition & 1 deletion app/cli/internal/trace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -88,7 +88,7 @@ What the session was asked to build, and which skills it used.
| Local spec sources read at push | A capture whose source is a local file holds only a header and a placeholder. Each push reads the file and records its current content, so the evidence follows the edits of the file. Only a regular text file of up to 1 MiB is read. Otherwise the push keeps the body that the agent wrote, or drops the capture with a warning when there is no body (spec issue-3561). | Yes | Yes | Yes | Yes |
| Spec source role, title, and description | Each captured spec source holds the role, the title, and the description that the agent wrote in its header (spec issue-3531). | Yes | Unknown | Unknown | Unknown |
| Skills tracking | The evidence lists the skills that the session used, with a copy of each skill as it ran. | Yes | No | Partial | Partial |
| Spec source pointers | Exact copies of spec sources in the transcript (full file reads and file writes) are replaced with a pointer to the spec material, so the evidence does not hold the same content two or three times (spec issue-3556). | Yes | No | No | No |
| Spec source pointers | Exact copies of spec sources in the transcript (full file reads and file writes) are replaced with a pointer to the spec material, so the evidence does not hold the same content two or three times (spec issue-3556). This includes the copies of pasted images and spec sources in the `api-request-blob` records of a Claude Code transcript (issue 3654). | Yes | No | No | No |
| Pasted image pointers | Each distinct image that the user pasted into the session, including images in prompts sent while the agent was busy, is stored once as an image spec entry, from the data in the transcript, with the title "Pasted image N" and no role. A repeated paste of the same image adds no entry. The inline image in the transcript is replaced with a pointer to that material. An image whose data cannot be decoded, or that the push could not store, stays inline, and the push continues. When the agent captured the same image, its entry is kept and no second entry is added (spec issue-3569). | Yes | No | No | No |

Why not Yes:
Expand Down
145 changes: 145 additions & 0 deletions app/cli/internal/trace/claude/requestblobs.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,145 @@
//
// Copyright 2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package claude

import (
"bytes"
"encoding/json"

"github.com/chainloop-dev/chainloop/app/cli/internal/trace/pointer"
)

// recordTypeRequestBlob is a record in which Claude Code keeps a copy of the
// blocks of one message of an API request: the prompt, the tool calls and
// their results. It holds the same copies of spec sources as the entries, a
// second time (issue 3654).
const recordTypeRequestBlob = "api-request-blob"

// isRequestBlob reports whether a transcript line is a request blob.
func isRequestBlob(line json.RawMessage) bool {
if !bytes.Contains(line, []byte(`"`+recordTypeRequestBlob+`"`)) {
return false
}

var head struct {
Type string `json:"type"`
}

return json.Unmarshal(line, &head) == nil && head.Type == recordTypeRequestBlob
}

// requestBlobLines returns the positions of the request blobs of a stream.
func requestBlobLines(lines []json.RawMessage) map[int]bool {
blobs := make(map[int]bool)
for i, l := range lines {
if isRequestBlob(l) {
blobs[i] = true
}
}

return blobs
}

// requestBlob replaces the copies of spec sources in one request blob, and
// returns its entry, or nil when the blob holds no blocks. A tool call or a
// tool result gets the pointer of the entry with the same tool call ID, only
// when it holds the same value: the copy in the request can differ, for
// example when the agent added text to it. An image gets a pointer when its
// data is a spec source.
func (r *specCopies) requestBlob(line json.RawMessage) *entry {
e := decodeEntry(line)
if e == nil {
return nil
}

for _, b := range e.content {
switch b.str("type") {
case blockToolUse:
c, ok := r.inputs[b.str("id")]
input := b.object("input")
if !ok || !sameJSON(input["content"], c.original) {
continue
}
res := r.report.Finder(pointer.FinderFileWrite)
res.BytesRemoved += swap(input, "content", c.pointer)
res.Replaced++
e.set(b, "input", input)
case blockToolResult:
if c, ok := r.results[b.str("tool_use_id")]; ok && sameJSON(b["content"], c.original) {
res := r.report.Finder(pointer.FinderFileRead)
res.BytesRemoved += len(b["content"])
res.Replaced++
b["content"] = c.pointer
e.changed = true
continue
}
// An image that the agent read is a block of the content.
blocks := b.objects("content")
n := 0
for _, img := range blocks {
if r.requestImage(img, pointer.FinderFileRead) {
n++
}
}
if n > 0 {
e.set(b, "content", blocks)
}
case blockImage:
if r.requestImage(b, pointer.FinderPastedImage) {
e.changed = true
}
}
}

return e
}

// requestImage replaces the data of an image block whose image is a spec
// source with a pointer to its material. It reports whether it replaced it.
func (r *specCopies) requestImage(b object, finder string) bool {
if b.str("type") != blockImage || b.object("source").str("type") != sourceBase64 {
return false
}
data, err := pastedImage{block: b}.decode()
if err != nil {
return false
}
material, ok := r.sources[pointer.Digest(data)]
if !ok {
return false
}

res := r.report.Finder(finder)
res.BytesRemoved += len(b["source"])
res.Replaced++
b["source"] = pointer.New(material)

return true
}

// sameJSON reports whether two encoded JSON values are equal, apart from
// white space.
func sameJSON(a, b json.RawMessage) bool {
if len(a) == 0 || len(b) == 0 {
return false
}
var ca, cb bytes.Buffer
if json.Compact(&ca, a) != nil || json.Compact(&cb, b) != nil {
return false
}

return bytes.Equal(ca.Bytes(), cb.Bytes())
}
162 changes: 162 additions & 0 deletions app/cli/internal/trace/claude/requestblobs_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,162 @@
//
// Copyright 2026 The Chainloop Authors.
//
// Licensed under the Apache License, Version 2.0 (the "License");
// you may not use this file except in compliance with the License.
// You may obtain a copy of the License at
//
// http://www.apache.org/licenses/LICENSE-2.0
//
// Unless required by applicable law or agreed to in writing, software
// distributed under the License is distributed on an "AS IS" BASIS,
// WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
// See the License for the specific language governing permissions and
// limitations under the License.

package claude

import (
"encoding/json"
"os"
"path/filepath"
"testing"

"github.com/chainloop-dev/chainloop/app/cli/internal/trace/pointer"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

// keyRole is the role of the message of a request blob.
const keyRole = "role"

// requestBlob is an api-request-blob record, as Claude Code writes it: a copy
// of the blocks of one message of an API request.
func requestBlob(role string, content ...any) obj {
return obj{keyType: recordTypeRequestBlob, "hash": "h1", "sessionId": "s1", keyMessage: obj{keyRole: role, keyContent: content}}
}

// blocksOf returns the content blocks of the message of an entry.
func blocksOf(e obj) []any {
return e[keyMessage].(obj)[keyContent].([]any)
}

func TestReplaceRequestBlobs(t *testing.T) {
const pastedMaterial = "sha256:pasted-material"

dir := t.TempDir()
config := filepath.Join(dir, "config.json")
require.NoError(t, os.WriteFile(config, []byte(jsonText), 0o600))
ticket := filepath.Join(dir, ".chainloop", "specs", "s1", "ticket-eng-1234.md")

sources := pointer.Sources{
pointer.Digest([]byte(ticketText)): ticketMaterial,
pointer.Digest([]byte(jsonText)): jsonMaterial,
pointer.Digest(pastedBytes): pastedMaterial,
}

read := readCall("r1", config, nil)
readResult := textResult("r1", config, jsonText, 1, 2, 2)
write := writeCall("w1", ticket, ticketText)
written := writeResult("w1", ticket, ticketText)

testCases := []struct {
name string
session []obj
check func(t *testing.T, out []json.RawMessage)
wantReport map[string]pointer.Result
}{
{
name: "the copy of a write in a request blob after the entry",
session: []obj{
write,
written,
requestBlob(typeAssistant, blocksOf(write)...),
requestBlob(typeUser, blocksOf(written)...),
},
check: func(t *testing.T, out []json.RawMessage) {
// The entry itself gets its pointers, also when a blob with the
// same call comes after it.
input := at(t, decode(t, out[0]), keyMessage, keyContent, 0, "input").(obj)
assert.NotContains(t, input, keyContent)
assert.Equal(t, ptr(ticketMaterial), input[pointer.Marker])
meta := at(t, decode(t, out[1]), keyToolUseResult).(obj)
assert.Equal(t, ptr(ticketMaterial), meta[pointer.Marker])

blobInput := at(t, decode(t, out[2]), keyMessage, keyContent, 0, "input").(obj)
assert.NotContains(t, blobInput, keyContent)
assert.Equal(t, ptr(ticketMaterial), blobInput[pointer.Marker])
// The result of a write holds no copy, so it keeps its bytes.
assert.Equal(t, line(t, requestBlob(typeUser, blocksOf(written)...)), out[3])
},
wantReport: map[string]pointer.Result{pointer.FinderFileWrite: {Replaced: 3}},
},
{
name: "the copy of a full read in a request blob",
session: []obj{
read,
readResult,
requestBlob(typeAssistant, blocksOf(read)...),
requestBlob(typeUser, blocksOf(readResult)...),
},
check: func(t *testing.T, out []json.RawMessage) {
assert.Equal(t, []any{ptr(jsonMaterial)}, at(t, decode(t, out[1]), keyMessage, keyContent, 0, keyContent))
assert.Equal(t, []any{ptr(jsonMaterial)}, at(t, decode(t, out[3]), keyMessage, keyContent, 0, keyContent))
// The call holds no copy.
assert.Equal(t, line(t, requestBlob(typeAssistant, blocksOf(read)...)), out[2])
},
wantReport: map[string]pointer.Result{pointer.FinderFileRead: {Replaced: 3}},
},
{
name: "a request blob whose result is not the same as the entry keeps its content",
session: []obj{
read,
readResult,
requestBlob(typeUser, obj{keyType: blockToolResult, keyToolUseID: "r1", keyContent: " 1\t" + jsonText + "<system-reminder>more</system-reminder>"}),
},
check: func(t *testing.T, out []json.RawMessage) {
assert.Contains(t, string(out[2]), "<system-reminder>more</system-reminder>")
assert.NotContains(t, string(out[2]), pointer.Marker)
},
wantReport: map[string]pointer.Result{pointer.FinderFileRead: {Replaced: 2}},
},
{
name: "a pasted image in a request blob",
session: []obj{
paste("[Image #1] look", []int{1}, imageBlock(pngType, pastedBytes)),
requestBlob(typeUser, obj{keyType: resultText, resultText: "<system-reminder>x</system-reminder>"}, imageBlock(pngType, pastedBytes), obj{keyType: resultText, resultText: "look"}),
},
check: func(t *testing.T, out []json.RawMessage) {
assert.Equal(t, ptr(pastedMaterial), at(t, decode(t, out[0]), keyMessage, keyContent, 1, keySource))
blob := decode(t, out[1])
assert.Equal(t, blockImage, at(t, blob, keyMessage, keyContent, 1, keyType))
assert.Equal(t, ptr(pastedMaterial), at(t, blob, keyMessage, keyContent, 1, keySource))
},
wantReport: map[string]pointer.Result{pointer.FinderPastedImage: {Replaced: 2}},
},
{
name: "an image in a request blob that is not a source keeps its data",
session: []obj{
requestBlob(typeUser, imageBlock(pngType, otherPasted)),
},
check: func(t *testing.T, out []json.RawMessage) {
assert.Equal(t, line(t, requestBlob(typeUser, imageBlock(pngType, otherPasted))), out[0])
},
},
}

for _, tc := range testCases {
t.Run(tc.name, func(t *testing.T) {
lines := make([]json.RawMessage, 0, len(tc.session))
for _, e := range tc.session {
lines = append(lines, line(t, e))
}

raw := map[string][]json.RawMessage{mainStream: lines}
report := replaceSpecCopies(raw, sources)

got, _ := results(report)
assert.Equal(t, tc.wantReport, got)
tc.check(t, raw[mainStream])
})
}
}
Loading
Loading