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
14 changes: 7 additions & 7 deletions app/cli/internal/trace/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -14,8 +14,8 @@ How Trace is added to a repository and to an agent.

| Feature | What it means | Claude Code | Cursor | OpenCode 1.x | OpenCode 2 |
|---------|---------------|-------------|--------|--------------|------------|
| Hook installation and deletion | `chainloop trace init` installs the hooks of the selected agent. A second init changes nothing. `chainloop trace uninstall` deletes only the Chainloop hooks and keeps the other hooks of the agent. | Yes | Unknown | Unknown | Unknown |
| Trace run wrapper | `chainloop trace run -- <agent command>` gives the agent temporary hooks for one session. When the command exits, the configuration of the agent is the same as before. | Yes | Unknown | Unknown | Unknown |
| Hook installation and deletion | `chainloop trace init` installs the hooks of the selected agent. A second init changes nothing. `chainloop trace uninstall` deletes only the Chainloop hooks and keeps the other hooks of the agent. | Yes | Unknown | Unknown | Yes |
Comment thread
cubic-dev-ai[bot] marked this conversation as resolved.
| Trace run wrapper | `chainloop trace run -- <agent command>` gives the agent temporary hooks for one session. When the command exits, the configuration of the agent is the same as before. | Yes | Unknown | Unknown | Yes |
| More than one agent in one repository | Two agents initialized in the same repository record their sessions separately, and each commit names the sessions of the agent that made it. | Unknown | Unknown | Unknown | Unknown |

Why not Yes:
Expand All @@ -33,7 +33,7 @@ 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 |
| Model detection | The evidence names the primary model and the other models that the session used. | Yes | Partial | Yes | Yes |
| Subagents | The evidence holds the conversations of the subagents, and their token usage, as part of the session. | Yes | No | No | No |
| Missed session start | A session whose session-start hook did not fire is still recorded, from its first tool hook. | Yes | Unknown | Unknown | Unknown |
| Missed session start | A session whose session-start hook did not fire is still recorded, from its first tool hook. | Yes | Unknown | Unknown | Yes |

Why not Yes:

Expand All @@ -42,7 +42,7 @@ Why not Yes:
- **Model detection, Cursor:** the primary model comes from the session-start hook. The transcript does not tell the models, so the evidence does not list the other models.
- **Subagents, Cursor:** the provider does not read subagent conversations.
- **Subagents, OpenCode:** a subagent runs in a child session with its own export. It is not part of the evidence of its parent session.
- **Missed session start, Cursor and OpenCode:** not verified yet. The end-to-end suite finds the values (spec issue-3635).
- **Missed session start, Cursor and OpenCode 1.x:** not verified yet. The end-to-end suite finds the values (spec issue-3635).

## Code attribution

Expand Down Expand Up @@ -91,15 +91,15 @@ What the session was asked to build, and which skills it used.
| Spec capture instruction | At session start, the agent is told to capture the specs of the session (tickets, documents, plans, images) in the spec folder. A plan is captured only when it comes from an external source or from the plan mode of the agent: an agreement in the chat is not a plan. A pasted image is not captured, also when the agent has a file path for it. The agent tells the user in one line when it captures a file. | Yes | Yes | Yes | Yes |
| Spec capture reminder | At each user prompt, the agent is reminded to capture new or changed specs. | Yes | No | Yes | Yes |
| 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 |
| 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 | Yes |
| 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). 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:

- **Spec capture reminder, Cursor:** Cursor has no hook at each user prompt. The agent gets the session-start instruction only.
- **Spec source role, title, and description, Cursor and OpenCode:** not verified yet. The end-to-end suite finds the values (spec issue-3635).
- **Spec source role, title, and description, Cursor and OpenCode 1.x:** not verified yet. The end-to-end suite finds the values (spec issue-3635).
- **Skills tracking, Cursor:** the provider does not track skills. The evidence has no skill entries, which means "not recorded", not "no skill used".
- **Skills tracking, OpenCode:** only the skills that the model starts with the `skill` tool are counted. Skills that the user starts, and skills used in subagents, are not. A plugin installed by an earlier version does not send the skill folder for OpenCode 2: install the hooks again to update it.
- **Spec source pointers, Cursor and OpenCode:** the provider has no finder for the copies in its transcript yet. For OpenCode, the rebuilt transcript holds no tool outputs, so only the write copies would apply.
Expand Down Expand Up @@ -157,7 +157,7 @@ All agents share the git hooks `commit-msg`, `post-commit` and `pre-push`.

## End-to-end tests

The `e2e/` package checks the tables of this page against real agents (spec issue-3635). It runs each agent in headless mode with real models, in throwaway repositories with Trace in local storage mode. For now, it runs the Claude Code column only.
The `e2e/` package checks the tables of this page against real agents (spec issue-3635). It runs each agent in headless mode with real models, in throwaway repositories with Trace in local storage mode. For now, it runs the Claude Code and OpenCode 2 columns. OpenCode runs on a private server (`opencode run --standalone`) with `PWD` set to the workspace, because OpenCode takes its project folder from `PWD`. The OpenCode 2 model in `e2e/config.yaml` is an OpenRouter model, so the run needs `OPENROUTER_API_KEY`.

A feature row maps to its checks by the feature name. When you rename a feature in a table, rename it in `e2e/checks.go` too. The suite tests a cell marked **Yes**, **Partial**, or **Unknown** only when the row has a registered check. It reports the other cells as follows:

Expand Down
21 changes: 17 additions & 4 deletions app/cli/internal/trace/e2e/checks.go
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,7 @@ import (
"fmt"
"io/fs"
"os"
"path"
"path/filepath"
"strings"

Expand Down Expand Up @@ -165,7 +166,11 @@ func checkModel(s *Session) error {
if m == nil || m.Primary == "" {
return errors.New("the evidence has no primary model")
}
if !strings.Contains(strings.ToLower(m.Primary), strings.ToLower(s.Model)) {
// A model can have a provider prefix, for example
// amazon-bedrock/eu.anthropic.claude-haiku-5-5, that the evidence leaves
// out, and a short name, for example haiku, that the evidence spells out.
primary, want := strings.ToLower(m.Primary), strings.ToLower(path.Base(s.Model))
if !strings.Contains(primary, want) && !strings.Contains(want, primary) {
return fmt.Errorf("primary model %q is not the model of the session %q", m.Primary, s.Model)
}
return nil
Expand Down Expand Up @@ -356,15 +361,23 @@ func checkSecretRedacted(s *Session) error {
// secret, as internal/redaction writes it.
const redactedPlaceholder = "[CHAINLOOP_TRACE_REDACTED"

// checkAllowedSecretKept checks that the secret on the line with an allow
// marker is in the stored session. The line comes from the output of the
// tool that read the fixture. A transcript with no tool outputs holds no
// copy of the line, so it cannot show the feature.
func checkAllowedSecretKept(s *Session) error {
found, err := storedContains(s, fakeAllowedSecret)
if err != nil || found {
return err
}
marked, err := storedContains(s, allowMarker)
if err != nil {
return err
}
if !found {
return errors.New("the secret on the line with an allow marker was redacted, or the session did not read it")
if !marked {
return fmt.Errorf("%w: the stored session holds no copy of the fixture line, for example because the transcript has no tool outputs", errNotCovered)
}
return nil
return errors.New("the secret on the line with an allow marker was redacted")
}

func checkCommitTrailer(s *Session) error {
Expand Down
39 changes: 35 additions & 4 deletions app/cli/internal/trace/e2e/checks_more.go
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ import (
"errors"
"fmt"
"image/png"
"io/fs"
"os"
"path/filepath"
"slices"
Expand All @@ -36,9 +37,29 @@ import (
// commandTimeout limits a command that a check runs in the workspace.
const commandTimeout = 2 * time.Minute

// readHookConfig reads the hook configuration file of the harness.
// readHookFile reads a hook file of the harness. A missing file is empty.
func readHookFile(s *Session, path string) ([]byte, error) {
data, err := os.ReadFile(filepath.Join(s.Workspace.Dir, path))
if errors.Is(err, fs.ErrNotExist) {
return nil, nil
}
return data, err
}

// readHookConfig reads the hook configuration of the harness: the file of
// the Chainloop hooks, and the file of the hook of the user when it is
// another file.
func readHookConfig(s *Session) ([]byte, error) {
return os.ReadFile(filepath.Join(s.Workspace.Dir, s.Harness.HookConfig().Path))
hc := s.Harness.HookConfig()
out, err := readHookFile(s, hc.Path)
if err != nil || hc.UserFile() == hc.Path {
return out, err
}
user, err := readHookFile(s, hc.UserFile())
if err != nil {
return nil, err
}
return append(out, user...), nil
}

// checkHookInstallation checks that `trace init` installs the Chainloop
Expand Down Expand Up @@ -90,13 +111,23 @@ func checkHookInstallation(s *Session) error {
// checkHookConfigRestored checks that the hook configuration is the same as
// before the wrapped session.
func checkHookConfigRestored(s *Session) error {
got, err := readHookConfig(s)
hc := s.Harness.HookConfig()
got, err := readHookFile(s, hc.UserFile())
if err != nil {
return err
}
if string(got) != s.Harness.HookConfig().UserConfig {
if string(got) != hc.UserConfig {
return fmt.Errorf("the hook configuration changed after trace run: %s", got)
}
if hc.UserFile() == hc.Path {
return nil
}
if got, err = readHookFile(s, hc.Path); err != nil {
return err
}
if bytes.Contains(got, []byte(hc.ChainloopMarker)) {
return fmt.Errorf("trace run left Chainloop hooks in %s", hc.Path)
}
return nil
}

Expand Down
5 changes: 5 additions & 0 deletions app/cli/internal/trace/e2e/claude.go
Original file line number Diff line number Diff line change
Expand Up @@ -217,6 +217,11 @@ func (c *Claude) DisableSessionStart(repo string) error {
return os.WriteFile(path, data, 0o600)
}

// SkillPath implements Harness.
func (c *Claude) SkillPath(name string) string {
return ".claude/skills/" + name + "/SKILL.md"
}

// claudeStreamEvent is the part of a stream-json line that the suite reads. A
// hook event has type "system" and subtype "hook_response". Its stdout is
// the response that the hook printed.
Expand Down
5 changes: 5 additions & 0 deletions app/cli/internal/trace/e2e/config.yaml
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,11 @@ harnesses:
version: "2.1.301"
models:
- haiku
# The OpenRouter models need OPENROUTER_API_KEY in the environment.
OpenCode 2:
version: "2.0.22"
models:
- openrouter/anthropic/claude-haiku-5.5

# Runs and pass threshold of a cell whose feature depends on the model
# following an instruction (R-006, D-004). A cell of any other feature runs
Expand Down
39 changes: 38 additions & 1 deletion app/cli/internal/trace/e2e/e2e_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,9 +18,11 @@
package e2e

import (
"maps"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"testing"

Expand Down Expand Up @@ -74,16 +76,20 @@ func TestTraceMatrix(t *testing.T) {
runner := &Runner{
Expected: exp,
Config: cfg,
Harnesses: []Harness{&Claude{}},
Harnesses: configuredHarnesses(t, cfg),
Filter: Filter{Harnesses: envList(envHarness), Models: envList(envModel), Features: envList(envFeature)},
ChainloopBin: bin,
CLIVersion: version,
WorkDir: workDir,
Logf: t.Logf,
}

// An agent that does not work in its workspace commits in the checkout
// that runs the suite. The suite cannot stop that, but it must not pass.
head := sourceHead(t)
res, err := runner.Run(ctx)
require.NoError(t, err)
require.Equal(t, head, sourceHead(t), "an agent committed in the checkout that runs the suite, not in its workspace")

resultsDir := os.Getenv(envResultsDir)
if resultsDir == "" {
Expand Down Expand Up @@ -119,6 +125,37 @@ func TestTraceMatrix(t *testing.T) {
}
}

// harnesses are the adapters of the suite, by column name. The suite runs the
// ones that config.yaml lists.
var harnesses = map[string]Harness{
HarnessClaudeCode: &Claude{},
"OpenCode 2": &OpenCode{Major: "2"},
}

// configuredHarnesses returns the adapters of the harnesses that the suite
// config lists, in the order of their names.
func configuredHarnesses(t *testing.T, cfg *Config) []Harness {
t.Helper()

names := slices.Sorted(maps.Keys(cfg.Harnesses))
out := make([]Harness, 0, len(names))
for _, name := range names {
h, ok := harnesses[name]
require.True(t, ok, "the suite config lists harness %q, which has no adapter", name)
out = append(out, h)
}
return out
}

// sourceHead returns the HEAD commit of the checkout that runs the suite.
func sourceHead(t *testing.T) string {
t.Helper()

out, err := exec.Command("git", "rev-parse", "HEAD").Output()
require.NoError(t, err)
return strings.TrimSpace(string(out))
}

// buildChainloop builds the chainloop CLI under test and returns its path and
// its version.
func buildChainloop(t *testing.T) (string, string) {
Expand Down
23 changes: 19 additions & 4 deletions app/cli/internal/trace/e2e/harness.go
Original file line number Diff line number Diff line change
Expand Up @@ -38,19 +38,31 @@ type RunRequest struct {
Images [][]byte
}

// HookConfig describes the file where the harness reads its hooks.
// HookConfig describes the files where the harness reads its hooks.
type HookConfig struct {
// Path is the file, relative to the repository root.
// Path is the file of the Chainloop hooks, relative to the repository
// root.
Path string
// ChainloopMarker is a text that only a Chainloop hook in the file holds.
ChainloopMarker string
// UserConfig is the content of the file with one hook of the user and no
// Chainloop hook.
// UserPath is the file of the hook of the user, relative to the
// repository root. Empty means Path: the hooks share one file.
UserPath string
// UserConfig is the content of the user file with one hook of the user
// and no Chainloop hook.
UserConfig string
// UserMarker is a text that only the hook of the user holds.
UserMarker string
}

// UserFile returns the file of the hook of the user.
func (c HookConfig) UserFile() string {
if c.UserPath != "" {
return c.UserPath
}
return c.Path
}

// HookMessage is a message that a Trace hook returned to the harness.
type HookMessage struct {
// Event is the hook event, for example SessionStart.
Expand Down Expand Up @@ -93,4 +105,7 @@ type Harness interface {
// installed in the repository, so that a session starts with no
// session-start hook.
DisableSessionStart(repo string) error
// SkillPath returns the file of a project skill, relative to the
// repository root.
SkillPath(name string) string
}
Loading
Loading