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
9 changes: 8 additions & 1 deletion app/cli/internal/trace/claude/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -35,7 +35,12 @@ const (
eventSessionStart = "SessionStart"
eventPreToolUse = "PreToolUse"
eventPostToolUse = "PostToolUse"
eventSessionEnd = "SessionEnd"
// eventPostToolUseFailure fires instead of PostToolUse when a tool call
// fails. A failed shell command can still have changed files (e.g. a
// script that writes files and then runs a failing linter), so it runs
// the same handler.
eventPostToolUseFailure = "PostToolUseFailure"
eventSessionEnd = "SessionEnd"
)

// fileWritingTools is the single source of truth for Claude tool names that modify files.
Expand All @@ -61,6 +66,7 @@ var hookEvents = []hookEvent{
{eventSessionStart, "chainloop trace hook claude session-start", ""},
{eventPreToolUse, "chainloop trace hook claude pre-tool-use", hookToolMatcher},
{eventPostToolUse, "chainloop trace hook claude post-tool-use", hookToolMatcher},
{eventPostToolUseFailure, "chainloop trace hook claude post-tool-use", hookToolMatcher},
{eventSessionEnd, "chainloop trace hook claude session-end", ""},
}

Expand Down Expand Up @@ -194,6 +200,7 @@ func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) {
if raw.ToolInput.FilePath != "" {
input.FilePath = raw.ToolInput.FilePath
}
input.ToolFailed = input.HookEventName == eventPostToolUseFailure

return &input, nil
}
Expand Down
40 changes: 40 additions & 0 deletions app/cli/internal/trace/claude/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ package claude
import (
"bytes"
"encoding/json"
"fmt"
"os"
"path/filepath"
"testing"
Expand Down Expand Up @@ -45,6 +46,9 @@ func TestInstallHooks(t *testing.T) {
assertHookCommand(t, hooks, "SessionStart", "chainloop trace hook claude session-start")
assertHookCommand(t, hooks, "PreToolUse", "chainloop trace hook claude pre-tool-use")
assertHookCommand(t, hooks, "PostToolUse", "chainloop trace hook claude post-tool-use")
// A failed tool call fires PostToolUseFailure instead of PostToolUse,
// and a failed shell command can still have changed files.
assertHookCommand(t, hooks, "PostToolUseFailure", "chainloop trace hook claude post-tool-use")
})

t.Run("installs PreToolUse and PostToolUse with matchers", func(t *testing.T) {
Expand All @@ -64,6 +68,11 @@ func TestInstallHooks(t *testing.T) {
postEntry := postEntries[0].(map[string]any)
assert.Equal(t, "Edit|Write|MultiEdit|Bash", postEntry["matcher"])

// PostToolUseFailure should have the same matcher
failureEntries := hooks["PostToolUseFailure"].([]any)
failureEntry := failureEntries[0].(map[string]any)
assert.Equal(t, "Edit|Write|MultiEdit|Bash", failureEntry["matcher"])

// SessionStart should NOT have matcher
startEntries := hooks["SessionStart"].([]any)
startEntry := startEntries[0].(map[string]any)
Expand Down Expand Up @@ -203,6 +212,7 @@ func TestUninstallHooks(t *testing.T) {
assert.Contains(t, hooks, "PostToolUse")
assert.NotContains(t, hooks, "SessionStart")
assert.NotContains(t, hooks, "PreToolUse")
assert.NotContains(t, hooks, "PostToolUseFailure")
})

t.Run("noop when file does not exist", func(t *testing.T) {
Expand All @@ -229,13 +239,15 @@ func TestReadHookInput(t *testing.T) {
"session_id": "abc-123",
"hook_event_name": "PreToolUse",
"tool_name": "Edit",
"tool_use_id": "toolu_01ABC",
"tool_input": {"file_path": "/some/file.go", "old_string": "foo"}
}`)
input, err := provider.ReadHookInput(r)
require.NoError(t, err)
assert.Equal(t, "abc-123", input.SessionID)
assert.Equal(t, "PreToolUse", input.HookEventName)
assert.Equal(t, "Edit", input.ToolName)
assert.Equal(t, "toolu_01ABC", input.ToolUseID)
assert.Equal(t, "/some/file.go", input.FilePath)
})

Expand All @@ -248,6 +260,33 @@ func TestReadHookInput(t *testing.T) {
assert.Empty(t, input.FilePath)
})

t.Run("flags failed tool calls", func(t *testing.T) {
testCases := []struct {
event string
wantFailed bool
}{
{event: "PreToolUse", wantFailed: false},
{event: "PostToolUse", wantFailed: false},
{event: "PostToolUseFailure", wantFailed: true},
}

for _, tc := range testCases {
t.Run(tc.event, func(t *testing.T) {
r := bytes.NewBufferString(fmt.Sprintf(`{
"session_id": "abc-123",
"hook_event_name": %q,
"tool_name": "Bash",
"tool_input": {"command": "make lint"},
"error": "Exit code 1",
"is_interrupt": false
}`, tc.event))
input, err := provider.ReadHookInput(r)
require.NoError(t, err)
assert.Equal(t, tc.wantFailed, input.ToolFailed)
})
}
})

t.Run("returns empty for empty session ID", func(t *testing.T) {
r := bytes.NewBufferString(`{"session_id":""}`)
input, err := provider.ReadHookInput(r)
Expand Down Expand Up @@ -316,6 +355,7 @@ func TestInstallHooksForTraceRun(t *testing.T) {
assert.Contains(t, hooks, "SessionStart")
assert.Contains(t, hooks, "PreToolUse")
assert.Contains(t, hooks, "PostToolUse")
assert.Contains(t, hooks, "PostToolUseFailure")
assert.NotContains(t, hooks, "SessionEnd", "trace run must not install SessionEnd; trace run drives end-of-session itself")
})
}
Expand Down
6 changes: 6 additions & 0 deletions app/cli/internal/trace/opencode/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -141,10 +141,13 @@ export const ChainloopTrace: Plugin = async ({ $, client }) => {
},
"tool.execute.before": async (input, output) => {
if (commandTools.includes(input.tool)) {
// callID pairs this hook with the tool.execute.after of the same
// call, so overlapping commands keep their own snapshots.
await fire("pre-tool-use", {
session_id: input.sessionID,
hook_event_name: "tool.execute.before",
tool_name: input.tool,
tool_use_id: input.callID,
Comment thread
migmartri marked this conversation as resolved.
})
return
}
Expand All @@ -164,6 +167,7 @@ export const ChainloopTrace: Plugin = async ({ $, client }) => {
session_id: input.sessionID,
hook_event_name: "tool.execute.after",
tool_name: input.tool,
tool_use_id: input.callID,
})
return
}
Expand Down Expand Up @@ -267,6 +271,7 @@ func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) {
HookEventName string `json:"hook_event_name"`
ToolName string `json:"tool_name"`
FilePath string `json:"file_path"`
ToolUseID string `json:"tool_use_id"`
}
if err := json.Unmarshal(data, &raw); err != nil {
return nil, err
Expand All @@ -277,6 +282,7 @@ func (p *Provider) ReadHookInput(r io.Reader) (*trace.HookInput, error) {
HookEventName: raw.HookEventName,
ToolName: raw.ToolName,
FilePath: raw.FilePath,
ToolUseID: raw.ToolUseID,
}, nil
}

Expand Down
10 changes: 10 additions & 0 deletions app/cli/internal/trace/opencode/hooks_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -139,6 +139,16 @@ func TestReadHookInputParsesValidInput(t *testing.T) {
assert.Equal(t, "/some/file.go", input.FilePath)
}

// The plugin sends opencode's callID as tool_use_id on shell hooks, so that
// the pre and post hooks of one command pair their snapshots.
func TestReadHookInputParsesToolUseID(t *testing.T) {
r := bytes.NewBufferString(`{"session_id":"ses_1","hook_event_name":"tool.execute.before","tool_name":"bash","tool_use_id":"call_01"}`)
p := New()
input, err := p.ReadHookInput(r)
require.NoError(t, err)
assert.Equal(t, "call_01", input.ToolUseID)
}

func TestReadHookInputApplyPatchSingleFile(t *testing.T) {
// apply_patch fires one hook per file, so each invocation still carries
// a single file_path — this is the shape the plugin emits after the fix.
Expand Down
4 changes: 4 additions & 0 deletions app/cli/internal/trace/opencode/testdata/plugin_full.ts
Original file line number Diff line number Diff line change
Expand Up @@ -85,10 +85,13 @@ export const ChainloopTrace: Plugin = async ({ $, client }) => {
},
"tool.execute.before": async (input, output) => {
if (commandTools.includes(input.tool)) {
// callID pairs this hook with the tool.execute.after of the same
// call, so overlapping commands keep their own snapshots.
await fire("pre-tool-use", {
session_id: input.sessionID,
hook_event_name: "tool.execute.before",
tool_name: input.tool,
tool_use_id: input.callID,
})
return
}
Expand All @@ -108,6 +111,7 @@ export const ChainloopTrace: Plugin = async ({ $, client }) => {
session_id: input.sessionID,
hook_event_name: "tool.execute.after",
tool_name: input.tool,
tool_use_id: input.callID,
})
return
}
Expand Down
4 changes: 4 additions & 0 deletions app/cli/internal/trace/opencode/testdata/plugin_tracerun.ts
Original file line number Diff line number Diff line change
Expand Up @@ -81,10 +81,13 @@ export const ChainloopTrace: Plugin = async ({ $, client }) => {
},
"tool.execute.before": async (input, output) => {
if (commandTools.includes(input.tool)) {
// callID pairs this hook with the tool.execute.after of the same
// call, so overlapping commands keep their own snapshots.
await fire("pre-tool-use", {
session_id: input.sessionID,
hook_event_name: "tool.execute.before",
tool_name: input.tool,
tool_use_id: input.callID,
})
return
}
Expand All @@ -104,6 +107,7 @@ export const ChainloopTrace: Plugin = async ({ $, client }) => {
session_id: input.sessionID,
hook_event_name: "tool.execute.after",
tool_name: input.tool,
tool_use_id: input.callID,
})
return
}
Expand Down
9 changes: 9 additions & 0 deletions app/cli/internal/trace/provider.go
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,10 @@ type HookInput struct {
// their parent's SessionID, so this is what tells concurrent agents of
// one session apart. Empty for the main agent.
AgentID string `json:"agent_id,omitempty"`
// ToolUseID is the agent's identifier for one tool call. Its pre and post
// hooks carry the same value, so it tells overlapping calls of one agent
// apart. Empty when the agent does not report it.
ToolUseID string `json:"tool_use_id,omitempty"`
// AgentVersion is the agent runtime version reported in the hook payload
// (e.g., Cursor's cursor_version). Captured at session-start so parsing
// can set Agent.Version even when the transcript itself doesn't carry it.
Expand All @@ -192,6 +196,11 @@ type HookInput struct {
// only emit post-edit events (e.g., Cursor's afterFileEdit) populate it so
// consumers can reconstruct the "before" content via reverse application.
Edits []HookEdit `json:"-"`
// ToolFailed reports that the hook fires after a tool call that failed
// (Claude's PostToolUseFailure). The tool can still have changed files,
// so its changes are recorded. But the agent expects a different hook
// response for a failure, so nothing is written back to it.
ToolFailed bool `json:"-"`
}

// HookEdit represents a single old_string → new_string replacement applied to a file.
Expand Down
6 changes: 6 additions & 0 deletions app/cli/internal/trace/state/session.go
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,12 @@ type SessionRecord struct {
// over Cwd to find the transcripts. Filled in by a later hook when the
// first one did not carry it. Empty when the agent does not report it.
TranscriptPath string `json:"transcript_path,omitempty"`
// Checkouts lists the roots of the other checkouts that this session
// edited with a file tool, sorted. Shell hooks run in the session's own
// checkout, and they also snapshot these checkouts, so that a command
// like `cd <other checkout> && …` is attributed there. Only set on the
// record in the session's own checkout.
Checkouts []string `json:"checkouts,omitempty"`
// Active reports whether the session is ongoing at the time of record write.
Active bool `json:"active"`
// StartedAt is the RFC3339 timestamp of when tracking began for this session.
Expand Down
65 changes: 44 additions & 21 deletions app/cli/internal/trace/state/snapshot.go
Original file line number Diff line number Diff line change
Expand Up @@ -52,26 +52,48 @@ func (s *Store) DeleteFileSnapshot(sessionID, filePath string) {
_ = os.Remove(path)
}

// ShellCallKey identifies the shell command that a pre-command signature
// belongs to, so the post-command hook of that command finds it.
type ShellCallKey struct {
SessionID string
// AgentID is empty for the main agent. Subagents share their parent's
// session ID, so this keeps their signatures apart.
AgentID string
// ToolUseID is the agent's identifier for the tool call. Empty when the
// agent does not report one. Then the agent has one slot, and its
// overlapping commands overwrite each other's signature.
ToolUseID string
}

// shellPreSignaturePath returns the path storing the pre-command working-tree
// signature for an agent of a session:
// <dir>/chainloop-trace/snapshots/<session>/shell-pre.json for the main agent,
// <dir>/chainloop-trace/snapshots/<session>/shell-pre-<agent>.json for a subagent.
func (s *Store) shellPreSignaturePath(sessionID, agentID string) string {
// signature of a shell command, under
// <dir>/chainloop-trace/snapshots/<session>/:
// - shell-pre-call-<tool use>.json when the call has an ID;
// - shell-pre.json for the main agent otherwise;
// - shell-pre-<agent>.json for a subagent otherwise.
//
// A tool use ID is unique within a session, so it needs no agent qualifier.
func (s *Store) shellPreSignaturePath(key ShellCallKey) string {
name := "shell-pre.json"
if agentID != "" {
name = "shell-pre-" + sanitizeID(agentID) + ".json"
switch {
case key.ToolUseID != "":
name = "shell-pre-call-" + sanitizeID(key.ToolUseID) + ".json"
case key.AgentID != "":
name = "shell-pre-" + sanitizeID(key.AgentID) + ".json"
}

return filepath.Join(s.traceDirPath(), traceDirSnapshots, sanitizeID(sessionID), name)
return filepath.Join(s.traceDirPath(), traceDirSnapshots, sanitizeID(key.SessionID), name)
}

// SaveShellPreSignature stores the working-tree signature captured before an
// agent-run shell command, so the post-command hook can diff against it.
// agentID is empty for the main agent. Subagents share their parent's session
// ID, so each agent gets its own slot; concurrent shell calls of one agent in
// one turn still overwrite it (see the parallel-shell limitation).
func (s *Store) SaveShellPreSignature(sessionID, agentID string, sig map[string]string) error {
path := s.shellPreSignaturePath(sessionID, agentID)
// WorktreeSignatures holds the working-tree signatures that a shell command
// is diffed against: checkout root → (repo-relative path → content hash). A
// command can change files in each checkout that its session edits.
type WorktreeSignatures map[string]map[string]string

// SaveShellPreSignature stores the working-tree signatures captured before an
// agent-run shell command, so the post-command hook can diff against them.
func (s *Store) SaveShellPreSignature(key ShellCallKey, sig WorktreeSignatures) error {
path := s.shellPreSignaturePath(key)
if err := os.MkdirAll(filepath.Dir(path), 0755); err != nil {
return fmt.Errorf("create snapshot dir: %w", err)
}
Expand All @@ -84,15 +106,16 @@ func (s *Store) SaveShellPreSignature(sessionID, agentID string, sig map[string]
return os.WriteFile(path, data, 0600)
}

// LoadShellPreSignature loads the pre-command working-tree signature for an
// agent of a session.
func (s *Store) LoadShellPreSignature(sessionID, agentID string) (map[string]string, error) {
data, err := os.ReadFile(s.shellPreSignaturePath(sessionID, agentID))
// LoadShellPreSignature loads the pre-command working-tree signatures of a
// shell command. A file in the earlier single-checkout format (written by an
// older CLI just before an upgrade) does not parse, and the caller skips it.
func (s *Store) LoadShellPreSignature(key ShellCallKey) (WorktreeSignatures, error) {
data, err := os.ReadFile(s.shellPreSignaturePath(key))
if err != nil {
return nil, err
}

var sig map[string]string
var sig WorktreeSignatures
if err := json.Unmarshal(data, &sig); err != nil {
return nil, fmt.Errorf("parse shell signature: %w", err)
}
Expand All @@ -101,6 +124,6 @@ func (s *Store) LoadShellPreSignature(sessionID, agentID string) (map[string]str
}

// DeleteShellPreSignature removes the pre-command signature once processed.
func (s *Store) DeleteShellPreSignature(sessionID, agentID string) {
_ = os.Remove(s.shellPreSignaturePath(sessionID, agentID))
func (s *Store) DeleteShellPreSignature(key ShellCallKey) {
_ = os.Remove(s.shellPreSignaturePath(key))
}
Loading
Loading