Skip to content
Closed
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
20 changes: 17 additions & 3 deletions app/cli/internal/trace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,11 @@ Pi supports the JSONL v3 format used by Pi 0.80.10. The compatibility suite pins

Pi evidence preserves native `input`, `output`, `cacheRead`, and `cacheWrite` as separate categories. Evidence `total_tokens` is recomputed as base input + output, excluding both cache categories, regardless of supplied `totalTokens`. The 100/20/10/5 fixture therefore records total 120, not 135. Supplied `cost.total` remains authoritative, without repricing. Invalid or overflowing counters are ignored with deterministic warnings; if input + output overflows, the evidence total is omitted with a warning while individual counters remain. Missing usage contributes zero. Only the selected branch is counted, including its history after resume, not abandoned branches or complete session spend. The Chainloop evidence field `schema_version` remains `v1`; it is not a Pi release or JSONL format version. Earlier Chainloop parsers used supplied totals, which may include cache, under the same evidence version; normalize historical evidence from raw records before combining totals.

Pi 1.3.0 passed 26 feature checks in the local end-to-end suite, including a cache-active accounting run. Pi 0.80.10 also completed targeted TUI and JSON-headless smokes that exercised session start, `write`, `edit`, `bash`, commit attribution and session shutdown; the TUI showed the local-mode welcome message. Subagents, skills tracking, spec source pointers and pasted image pointers are not implemented. The headless suite cannot verify the UI-only welcome message, the control-plane credentials warning, the session link after a control-plane push or control-plane attestation. The multi-agent feature has no registered check.
Pi 1.3.0 passed 26 feature checks in the local end-to-end suite, including a cache-active accounting run. Pi 0.80.10 also completed targeted TUI and JSON-headless smokes that exercised session start, `write`, `edit`, `bash`, commit attribution and session shutdown; the TUI showed the local-mode welcome message. The new pinned-format subagent scenario was attempted with Pi 1.3.0, but could not run because the validation environment has no OpenRouter API key. Skills tracking, spec source pointers and pasted image pointers are not implemented. The headless suite cannot verify the UI-only welcome message, the control-plane credentials warning, the session link after a control-plane push or control-plane attestation. The multi-agent feature has no registered check.

Pi subagent support is limited to the official example extension's completed result format at [commit `71023aec1546d6d677a65669c5665c0927ce0dd0`](https://github.com/earendil-works/pi/tree/71023aec1546d6d677a65669c5665c0927ce0dd0/packages/coding-agent/examples/extensions/subagent), shipped in [Pi 1.3.0](https://github.com/earendil-works/pi/blob/71023aec1546d6d677a65669c5665c0927ce0dd0/packages/coding-agent/package.json). The supporting sources are [`index.ts`](https://github.com/earendil-works/pi/blob/71023aec1546d6d677a65669c5665c0927ce0dd0/packages/coding-agent/examples/extensions/subagent/index.ts), [`agents.ts`](https://github.com/earendil-works/pi/blob/71023aec1546d6d677a65669c5665c0927ce0dd0/packages/coding-agent/examples/extensions/subagent/agents.ts), and its [README](https://github.com/earendil-works/pi/blob/71023aec1546d6d677a65669c5665c0927ce0dd0/packages/coding-agent/examples/extensions/subagent/README.md). This does not claim arbitrary third-party extension support.

For `single`, `parallel`, and `chain` results on the selected root branch, Trace reads `toolResult.details.results`, assigns IDs `subagent-<result-entry-id>-<1-based-result-index>`, and preserves each child's native messages, including tool results, in its own `raw_session` stream. Child messages are wrapped as Pi entries; missing message timestamps use the containing result's timestamp. Root conversation and tool summaries stay root-only, and the primary model stays the last selected root assistant's model. Child assistant models join `models_used`. Usage adds the completed result's aggregate `input`, `output`, `cacheRead`, `cacheWrite`, and numeric `cost` exactly once, not again from embedded assistant messages. Cache stays separate, `total_tokens` remains input + output, cost is not repriced, and `schema_version` remains `v1`. Failed children are retained; unavailable conversations or malformed optional data produce best-effort evidence and warnings. Abandoned branches and unrelated tools do not contribute children. The example runs children with `--no-session`; `parentSession` is not used to infer children.

Codex uses native hooks in `.codex/hooks.json` (Codex CLI 0.163.0). Run `chainloop trace init --codex` or `chainloop trace run --local --codex -- codex exec …`. Review and trust the installed hooks in Codex before starting a session. See the [official hooks documentation](https://learn.chatgpt.com/docs/hooks). Installed hooks have a `Chainloop Trace` status label in Codex. Re-running init adds labels to existing Chainloop hooks. Changing the hooks requires a new trust review.

Expand Down Expand Up @@ -53,12 +57,12 @@ What the session evidence holds about the conversation.
| Token usage and cost | The evidence holds the tokens that the session used and their cost in USD. | Yes | No | Yes | Yes | Yes | Yes |
| Cache token accounting | Cache reads and writes stay in separate evidence fields. Base input and total exclude cache. The suite compares stored token counts with the raw usage records. | Unknown | No | Unknown | Unknown | Yes | Unknown |
| Model detection | The evidence names the primary model and the other models that the session used. | Yes | Partial | Yes | Yes | Yes | Yes |
| Subagents | The evidence holds the conversations of the subagents, and their token usage, as part of the session. | Yes | No | No | No | No | No |
| Subagents | The evidence holds the conversations of the subagents, and their token usage, as part of the session. | Yes | No | No | No | Partial | No |

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2: The Pi Subagents cell is set to Partial without any end-to-end or manual result. This file says Pi cells stay Unknown until a live result or explicit manual check exists, and the new live scenario did not run. Set this cell back to Unknown until the pinned-format scenario passes.

Prompt for AI agents
Check if this issue is valid — if so, understand the root cause and fix it. When an issue isn't valid or won't be fixed in this PR, reply in its thread with the reason and then resolve the thread. At app/cli/internal/trace/README.md, line 60:

<comment>The Pi Subagents cell is set to **Partial** without any end-to-end or manual result. This file says Pi cells stay **Unknown** until a live result or explicit manual check exists, and the new live scenario did not run. Set this cell back to **Unknown** until the pinned-format scenario passes.</comment>

<file context>
@@ -53,12 +57,12 @@ What the session evidence holds about the conversation.
 | Cache token accounting | Cache reads and writes stay in separate evidence fields. Base input and total exclude cache. The suite compares stored token counts with the raw usage records. | Unknown | No | Unknown | Unknown | Yes | Unknown |
 | Model detection | The evidence names the primary model and the other models that the session used. | Yes | Partial | Yes | Yes | Yes | Yes |
-| Subagents | The evidence holds the conversations of the subagents, and their token usage, as part of the session. | Yes | No | No | No | No | No |
+| Subagents | The evidence holds the conversations of the subagents, and their token usage, as part of the session. | Yes | No | No | No | Partial | No |
 | Missed session start | A session whose session-start hook did not fire is still recorded, from its first tool hook. | Yes | Unknown | Unknown | Yes | Yes | Unknown |
 
</file context>
Suggested change
| Subagents | The evidence holds the conversations of the subagents, and their token usage, as part of the session. | Yes | No | No | No | Partial | No |
| Subagents | The evidence holds the conversations of the subagents, and their token usage, as part of the session. | Yes | No | No | No | Unknown | No |

| Missed session start | A session whose session-start hook did not fire is still recorded, from its first tool hook. | Yes | Unknown | Unknown | Yes | Yes | Unknown |

Why not Yes:

- **Subagents, Pi:** the provider records the selected root session only. Child session transcripts are not collected.
- **Subagents, Pi:** only the commit-pinned official example result format is supported, not arbitrary extensions or inferred child sessions. Parser and packaging regressions cover all three modes, usage, and child content; the supported-format live scenario is blocked by missing OpenRouter authentication.
- **Missed session start, Codex:** the scenario produces evidence, but its check also needs hook messages that Codex does not report in headless mode.
- **Transcript, OpenCode:** OpenCode has no transcript file. The provider runs `opencode export` (1.x) or `opencode session export` (2) and rebuilds the transcript from the export. It keeps the text and the inputs of the tool calls. It drops the tool outputs, the reasoning and the file parts. OpenCode 2 exports also have no agent version and no session slug.
- **Token usage and cost, Cursor:** the Cursor transcript does not hold token usage. Usage and cost are zero, and the evidence has a warning that tells so. (For OpenCode, the cost is the value that OpenCode reports. For Claude Code, Chainloop computes it from its price table.)
Expand Down Expand Up @@ -243,6 +247,16 @@ To run Pi only, set `OPENROUTER_API_KEY` in your environment, then run:
CHAINLOOP_E2E_HARNESS=Pi make -C app/cli test-trace-e2e
```

To check the supported Pi subagent format only, use an npm installation of Pi 1.3.0 with its shipped examples and set `OPENROUTER_API_KEY`, then run:

```bash
CHAINLOOP_E2E_HARNESS=Pi \
CHAINLOOP_E2E_FEATURE=Subagents \
make -C app/cli test-trace-e2e
```

This scenario loads the real official example from the installed package and checks the SHA-256 of both `index.ts` and `agents.ts` against the pinned commit before running. It uses an isolated read-only worker, then lets the parent write, commit, and push after the child returns. Its check compares stored child conversations and tool-result content with the native completed result and verifies parent plus child usage, cache, and cost exactly once. A manual Pi 1.3.0 check with `openai-codex/gpt-6.1-sol` completed one real worker using the hash-verified official example. The pinned OpenRouter matrix remains blocked by an expired API key; the manual check is not a pass for that matrix.

To retain the workspace and agent output for a failure:

```bash
Expand Down
3 changes: 3 additions & 0 deletions app/cli/internal/trace/e2e/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -437,6 +437,9 @@ func checkTicketPointer(s *Session) error {
}

func checkSubagents(s *Session) error {
if evidence(s).Agent.Name == "pi" {
return checkPiSubagents(evidence(s))
}
subs := evidence(s).Subagents
if len(subs) == 0 {
return errors.New("the evidence has no subagent")
Expand Down
191 changes: 191 additions & 0 deletions app/cli/internal/trace/e2e/checks_pi.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,191 @@
// 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 e2e

import (
"bytes"
"encoding/json"
"errors"
"fmt"
"math"
"math/big"
"reflect"

"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession"
)

type piSourceRecord struct {
Type string `json:"type"`
ID string `json:"id"`
Message struct {
Role string `json:"role"`
ToolName string `json:"toolName"`
Usage map[string]json.RawMessage `json:"usage"`
Details struct {
Mode string `json:"mode"`
Results []json.RawMessage `json:"results"`
} `json:"details"`
} `json:"message"`
}

type piSourceChild struct {
Agent string `json:"agent"`
Task string `json:"task"`
Usage map[string]json.RawMessage `json:"usage"`
Messages []json.RawMessage `json:"messages"`
}

// Compare stored children with native completed results, independently of the
// provider parser. Nonempty aggregate metadata alone cannot pass this check.
func checkPiSubagents(d *aicodingsession.Data) error {
var expected []aicodingsession.Subagent
hasToolResult := false
for _, raw := range d.RawSession["main"] {
var r piSourceRecord
if json.Unmarshal(raw, &r) != nil || !piSourceSubagent(r) {
continue
}
for index, rawChild := range r.Message.Details.Results {
var child piSourceChild
if err := json.Unmarshal(rawChild, &child); err != nil {
return fmt.Errorf("decode Pi source child: %w", err)
}
id := fmt.Sprintf("subagent-%s-%d", r.ID, index+1)
var usage aicodingsession.Usage
addPiSourceUsage(&usage, child.Usage, child.Usage["cost"])
expected = append(expected, aicodingsession.Subagent{
ID: id, Type: child.Agent, Description: child.Task,
Tokens: aicodingsession.SubagentTokens{Input: usage.InputTokens, Output: usage.OutputTokens},
})
stream := d.RawSession[id]
if len(stream) == 0 || len(stream) != len(child.Messages) {
return fmt.Errorf("Pi child %s conversation is missing or incomplete", id)
}
for i, source := range child.Messages {
var entry struct {
Message json.RawMessage `json:"message"`
}
if json.Unmarshal(stream[i], &entry) != nil {
return fmt.Errorf("Pi child %s has an invalid stored message", id)
}
// Material assembly may indent or reorder fields.
var stored, original bytes.Buffer
if json.Compact(&stored, entry.Message) != nil || json.Compact(&original, source) != nil {
return fmt.Errorf("Pi child %s has invalid message JSON", id)
}
var got, want map[string]json.RawMessage
if json.Unmarshal(stored.Bytes(), &got) != nil || json.Unmarshal(original.Bytes(), &want) != nil || !reflect.DeepEqual(got, want) {
return fmt.Errorf("Pi child %s message %d differs from source content", id, i+1)
}
var message struct {
Role string `json:"role"`
Content []struct {
Text string `json:"text"`
} `json:"content"`
}
if json.Unmarshal(source, &message) == nil && message.Role == "toolResult" {
for _, block := range message.Content {
hasToolResult = hasToolResult || block.Text != ""
}
}
}
}
}
if len(expected) == 0 || !reflect.DeepEqual(expected, d.Subagents) {
return errors.New("Pi subagents do not match native completed results")
}
if !hasToolResult {
return errors.New("Pi subagent scenario has no readable child tool result")
}
if len(d.RawSession) != len(expected)+1 {
return errors.New("Pi evidence has unrelated child streams")
}
want := piSourceUsage(d.RawSession["main"])
if d.SchemaVersion != "v1" || d.Usage == nil || *d.Usage != want {
return fmt.Errorf("Pi usage does not count parent and child aggregates exactly once: got %+v, want %+v", d.Usage, want)
}
return nil
}

func piSourceSubagent(r piSourceRecord) bool {
if r.Type != "message" || r.Message.Role != "toolResult" || r.Message.ToolName != "subagent" {
return false
}
switch r.Message.Details.Mode {
case "single", "parallel", "chain":
return true
default:
return false
}
}

func piSourceUsage(records []json.RawMessage) aicodingsession.Usage {
var total aicodingsession.Usage
for _, raw := range records {
var r piSourceRecord
if json.Unmarshal(raw, &r) != nil || r.Type != "message" {
continue
}
if r.Message.Role == "assistant" {
var cost struct {
Total json.RawMessage `json:"total"`
}
if json.Unmarshal(r.Message.Usage["cost"], &cost) != nil {
cost.Total = nil
}
addPiSourceUsage(&total, r.Message.Usage, cost.Total)
}
if piSourceSubagent(r) {
for _, rawChild := range r.Message.Details.Results {
var child piSourceChild
if json.Unmarshal(rawChild, &child) == nil && child.Agent != "" {
addPiSourceUsage(&total, child.Usage, child.Usage["cost"])
}
}
}
}
if total.OutputTokens <= int(^uint(0)>>1)-total.InputTokens {
total.TotalTokens = total.InputTokens + total.OutputTokens
}
if total.EstimatedCostUSD <= math.MaxFloat64/10000 {
total.EstimatedCostUSD = math.Round(total.EstimatedCostUSD*10000) / 10000
}
return total
}

func addPiSourceUsage(total *aicodingsession.Usage, usage map[string]json.RawMessage, rawCost json.RawMessage) {
for _, field := range []struct {
name string
total *int
}{
{"input", &total.InputTokens},
{"output", &total.OutputTokens},
{"cacheRead", &total.CacheReadInputTokens},
{"cacheWrite", &total.CacheCreationInputTokens},
} {
value, ok := new(big.Rat).SetString(string(usage[field.name]))
if !ok || value.Sign() < 0 || !value.IsInt() || !value.Num().IsInt64() {
continue
}
count := value.Num().Int64()
if count <= int64(int(^uint(0)>>1)-*field.total) {
*field.total += int(count)
}
}
var cost float64
if json.Unmarshal(rawCost, &cost) == nil && cost >= 0 && !math.IsInf(cost+total.EstimatedCostUSD, 0) {
total.EstimatedCostUSD += cost
}
}
111 changes: 111 additions & 0 deletions app/cli/internal/trace/e2e/checks_pi_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,111 @@
// 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 e2e

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

"github.com/chainloop-dev/chainloop/app/cli/internal/trace/pi"
"github.com/chainloop-dev/chainloop/pkg/attestation/crafter/materials/aicodingsession"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
)

const piCheckChildID = "subagent-result-1"

func piSubagentCheckSession() *Session {
const assistant = `{"role":"assistant","content":[{"type":"toolCall","id":"read-1","name":"read","arguments":{"path":"README.md"}}],"usage":{"input":3,"output":5,"cacheRead":11,"cacheWrite":19,"cost":{"total":0.025}}}`
const toolResult = `{"role":"toolResult","toolCallId":"read-1","toolName":"read","content":[{"type":"text","text":"file contents"}]}`
s := cacheSession(pi.Name,
`{"type":"message","id":"root","message":{"role":"assistant","usage":{"input":7,"output":9,"cacheRead":13,"cacheWrite":17,"cost":{"total":0.1}}}}`,
`{"type":"message","id":"result","message":{"role":"toolResult","toolName":"subagent","details":{"mode":"single","results":[{"agent":"worker","task":"read","usage":{"input":3,"output":5,"cacheRead":11,"cacheWrite":19,"cost":0.025},"messages":[`+assistant+`,`+toolResult+`]}]}}}`,
)
d := evidence(s)
d.Usage = &aicodingsession.Usage{
InputTokens: 10, OutputTokens: 14, TotalTokens: 24,
CacheReadInputTokens: 24, CacheCreationInputTokens: 36, EstimatedCostUSD: 0.125,
}
d.Subagents = []aicodingsession.Subagent{{
ID: piCheckChildID, Type: "worker", Description: "read",
Tokens: aicodingsession.SubagentTokens{Input: 3, Output: 5},
}}
d.RawSession[piCheckChildID] = []json.RawMessage{
json.RawMessage(`{"type":"message","message":` + assistant + `}`),
json.RawMessage(`{"type":"message","message":` + toolResult + `}`),
}
return s
}

func TestCheckPiSubagentsWhenStoredEvidenceChanges(t *testing.T) {
for _, tc := range []struct {
name string
change func(*aicodingsession.Data)
}{
{name: "complete child content and exact usage"},
{name: "missing child conversation", change: func(d *aicodingsession.Data) { delete(d.RawSession, piCheckChildID) }},
{name: "lost tool result", change: func(d *aicodingsession.Data) {
d.RawSession[piCheckChildID] = d.RawSession[piCheckChildID][:1]
}},
{name: "changed tool content", change: func(d *aicodingsession.Data) {
d.RawSession[piCheckChildID][1] = json.RawMessage(`{"message":{"role":"toolResult","content":[{"type":"text","text":"truncated"}]}}`)
}},
{name: "wrong child identity", change: func(d *aicodingsession.Data) { d.Subagents[0].ID = "wrong" }},
{name: "lost child tokens", change: func(d *aicodingsession.Data) { d.Subagents[0].Tokens.Input = 0 }},
{name: "double counted usage", change: func(d *aicodingsession.Data) { d.Usage.InputTokens += 3; d.Usage.TotalTokens += 3 }},
{name: "lost cache", change: func(d *aicodingsession.Data) { d.Usage.CacheReadInputTokens = 13 }},
{name: "lost native cost", change: func(d *aicodingsession.Data) { d.Usage.EstimatedCostUSD = 0.1 }},
{name: "unrelated stream", change: func(d *aicodingsession.Data) { d.RawSession["unrelated"] = nil }},
{name: "wrong evidence version", change: func(d *aicodingsession.Data) { d.SchemaVersion = "v2" }},
} {
t.Run(tc.name, func(t *testing.T) {
// Given
session := piSubagentCheckSession()
if tc.change != nil {
tc.change(evidence(session))
}
// When
err := checkSubagents(session)
// Then
if tc.change == nil {
require.NoError(t, err)
require.NoError(t, checkCacheTokenAccounting(session))
} else {
require.Error(t, err)
}
})
}
}

func TestPiSubagentSourcePinWhenMissingOrModified(t *testing.T) {
for _, modified := range []bool{false, true} {
t.Run(map[bool]string{false: "missing", true: "modified"}[modified], func(t *testing.T) {
// Given
dir := t.TempDir()
if modified {
require.NoError(t, os.WriteFile(filepath.Join(dir, "index.ts"), []byte("different extension"), 0o600))
}
// When
err := verifyPiSubagentSource(dir)
// Then
require.Error(t, err)
if modified {
assert.ErrorContains(t, err, piSubagentCommit)
}
})
}
}
Loading
Loading