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: 2 additions & 0 deletions cmd/entire/cli/agent/claudecode/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -133,6 +133,7 @@ func (c *ClaudeCodeAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) {
Type: agent.TurnEnd,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
Model: raw.Model,
Timestamp: time.Now(),
}, nil
}
Expand All @@ -146,6 +147,7 @@ func (c *ClaudeCodeAgent) parseSessionEnd(stdin io.Reader) (*agent.Event, error)
Type: agent.SessionEnd,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
Model: raw.Model,
Timestamp: time.Now(),
}, nil
}
Expand Down
34 changes: 34 additions & 0 deletions cmd/entire/cli/agent/claudecode/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,23 @@ func TestParseHookEvent_TurnEnd(t *testing.T) {
}
}

func TestParseHookEvent_TurnEnd_IncludesModel(t *testing.T) {
t.Parallel()

ag := &ClaudeCodeAgent{}
input := `{"session_id": "sess-stop-model", "transcript_path": "/tmp/stop.jsonl", "model": "claude-opus-4-6"}`

event, err := ag.ParseHookEvent(context.Background(), HookNameStop, strings.NewReader(input))

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
require.NotNil(t, event, "expected event, got nil")
if event.Model != "claude-opus-4-6" {
t.Errorf("expected model 'claude-opus-4-6', got %q", event.Model)
}
}

func TestParseHookEvent_SessionEnd(t *testing.T) {
t.Parallel()

Expand All @@ -139,6 +156,23 @@ func TestParseHookEvent_SessionEnd(t *testing.T) {
}
}

func TestParseHookEvent_SessionEnd_IncludesModel(t *testing.T) {
t.Parallel()

ag := &ClaudeCodeAgent{}
input := `{"session_id": "end-model", "transcript_path": "/tmp/end.jsonl", "model": "claude-sonnet-4-20250514"}`

event, err := ag.ParseHookEvent(context.Background(), HookNameSessionEnd, strings.NewReader(input))

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
require.NotNil(t, event, "expected event, got nil")
if event.Model != "claude-sonnet-4-20250514" {
t.Errorf("expected model 'claude-sonnet-4-20250514', got %q", event.Model)
}
}

func TestParseHookEvent_SubagentStart(t *testing.T) {
t.Parallel()

Expand Down
2 changes: 2 additions & 0 deletions cmd/entire/cli/agent/codex/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,7 @@ func (c *CodexAgent) parseTurnStart(stdin io.Reader) (*agent.Event, error) {
SessionID: raw.SessionID,
SessionRef: derefString(raw.TranscriptPath),
Prompt: raw.Prompt,
Model: raw.Model,
Timestamp: time.Now(),
}, nil
}
Expand All @@ -102,6 +103,7 @@ func (c *CodexAgent) parseTurnEnd(stdin io.Reader) (*agent.Event, error) {
Type: agent.TurnEnd,
SessionID: raw.SessionID,
SessionRef: derefString(raw.TranscriptPath),
Model: raw.Model,
Timestamp: time.Now(),
}, nil
}
2 changes: 2 additions & 0 deletions cmd/entire/cli/agent/codex/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,7 @@ func TestParseHookEvent_UserPromptSubmit(t *testing.T) {
require.Equal(t, "test-uuid", event.SessionID)
require.Equal(t, "/tmp/rollout.jsonl", event.SessionRef)
require.Equal(t, "Create a hello.txt file", event.Prompt)
require.Equal(t, "gpt-4.1", event.Model)
}

func TestParseHookEvent_Stop(t *testing.T) {
Expand All @@ -96,6 +97,7 @@ func TestParseHookEvent_Stop(t *testing.T) {
require.Equal(t, agent.TurnEnd, event.Type)
require.Equal(t, "test-uuid", event.SessionID)
require.Equal(t, "/tmp/rollout.jsonl", event.SessionRef)
require.Equal(t, "gpt-4.1", event.Model)
}

func TestParseHookEvent_PreToolUse_ReturnsNil(t *testing.T) {
Expand Down
2 changes: 2 additions & 0 deletions cmd/entire/cli/agent/cursor/lifecycle.go
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@ func (c *CursorAgent) parseSessionStart(stdin io.Reader) (*agent.Event, error) {
Type: agent.SessionStart,
SessionID: raw.ConversationID,
SessionRef: raw.TranscriptPath,
Model: raw.Model,
Timestamp: time.Now(),
Comment thread
Soph marked this conversation as resolved.
Comment thread
Soph marked this conversation as resolved.
}, nil
}
Expand Down Expand Up @@ -131,6 +132,7 @@ func (c *CursorAgent) parseSessionEnd(ctx context.Context, stdin io.Reader) (*ag
Type: agent.SessionEnd,
SessionID: raw.ConversationID,
SessionRef: c.resolveTranscriptRef(ctx, raw.ConversationID, raw.TranscriptPath),
Model: raw.Model,
DurationMs: intFromJSON(raw.DurationMs),
Timestamp: time.Now(),
}, nil
Expand Down
59 changes: 56 additions & 3 deletions cmd/entire/cli/agent/cursor/lifecycle_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,8 @@ import (
"github.com/stretchr/testify/require"
)

const testModel = "gpt-4o"

func TestParseHookEvent_SessionStart(t *testing.T) {
t.Parallel()

Expand All @@ -40,6 +42,40 @@ func TestParseHookEvent_SessionStart(t *testing.T) {
}
}

func TestParseHookEvent_SessionStart_IncludesModel(t *testing.T) {
t.Parallel()

ag := &CursorAgent{}
input := `{"conversation_id": "sess-model", "transcript_path": "/tmp/t.jsonl", "model": "` + testModel + `"}`

event, err := ag.ParseHookEvent(context.Background(), HookNameSessionStart, strings.NewReader(input))

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
require.NotNil(t, event, "expected event, got nil")
if event.Model != testModel {
t.Errorf("expected model %q, got %q", testModel, event.Model)
}
}

func TestParseHookEvent_SessionStart_EmptyModel(t *testing.T) {
t.Parallel()

ag := &CursorAgent{}
input := `{"conversation_id": "sess-no-model", "transcript_path": "/tmp/t.jsonl"}`

event, err := ag.ParseHookEvent(context.Background(), HookNameSessionStart, strings.NewReader(input))

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
require.NotNil(t, event, "expected event, got nil")
if event.Model != "" {
t.Errorf("expected empty model, got %q", event.Model)
}
}

func TestParseHookEvent_TurnStart(t *testing.T) {
t.Parallel()

Expand Down Expand Up @@ -67,15 +103,15 @@ func TestParseHookEvent_TurnStart_IncludesModel(t *testing.T) {
t.Parallel()

ag := &CursorAgent{}
input := `{"conversation_id": "sess-m", "transcript_path": "/tmp/t.jsonl", "prompt": "hi", "model": "gpt-4o"}`
input := `{"conversation_id": "sess-m", "transcript_path": "/tmp/t.jsonl", "prompt": "hi", "model": "` + testModel + `"}`

event, err := ag.ParseHookEvent(context.Background(), HookNameBeforeSubmitPrompt, strings.NewReader(input))

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
if event.Model != "gpt-4o" {
t.Errorf("expected model 'gpt-4o', got %q", event.Model)
if event.Model != testModel {
t.Errorf("expected model %q, got %q", testModel, event.Model)
}
}

Expand Down Expand Up @@ -166,6 +202,23 @@ func TestParseHookEvent_SessionEnd(t *testing.T) {
}
}

func TestParseHookEvent_SessionEnd_IncludesModel(t *testing.T) {
t.Parallel()

ag := &CursorAgent{}
input := `{"conversation_id": "end-model", "transcript_path": "/tmp/end.jsonl", "model": "` + testModel + `"}`

event, err := ag.ParseHookEvent(context.Background(), HookNameSessionEnd, strings.NewReader(input))

if err != nil {
t.Fatalf("unexpected error: %v", err)
}
require.NotNil(t, event, "expected event, got nil")
if event.Model != testModel {
t.Errorf("expected model %q, got %q", testModel, event.Model)
}
}

func TestParseHookEvent_TurnEnd_CLINoTranscriptPath(t *testing.T) {
ag := &CursorAgent{}
// Set up a temp dir that simulates the Cursor project dir with a flat transcript
Expand Down
6 changes: 6 additions & 0 deletions cmd/entire/cli/agent/vogon/hooks.go
Original file line number Diff line number Diff line change
Expand Up @@ -45,6 +45,7 @@ func (v *Agent) ParseHookEvent(_ context.Context, hookName string, stdin io.Read
Type: agent.SessionStart,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
Model: raw.Model,
Timestamp: time.Now(),
}, nil

Expand All @@ -58,6 +59,7 @@ func (v *Agent) ParseHookEvent(_ context.Context, hookName string, stdin io.Read
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
Prompt: raw.Prompt,
Model: raw.Model,
Timestamp: time.Now(),
}, nil

Expand All @@ -70,6 +72,7 @@ func (v *Agent) ParseHookEvent(_ context.Context, hookName string, stdin io.Read
Type: agent.TurnEnd,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
Model: raw.Model,
Timestamp: time.Now(),
}, nil

Expand All @@ -82,6 +85,7 @@ func (v *Agent) ParseHookEvent(_ context.Context, hookName string, stdin io.Read
Type: agent.SessionEnd,
SessionID: raw.SessionID,
SessionRef: raw.TranscriptPath,
Model: raw.Model,
Timestamp: time.Now(),
}, nil

Expand Down Expand Up @@ -117,10 +121,12 @@ func (v *Agent) WriteHookResponse(message string) error {
type sessionInfoRaw struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
Model string `json:"model,omitempty"`
}

type userPromptSubmitRaw struct {
SessionID string `json:"session_id"`
TranscriptPath string `json:"transcript_path"`
Prompt string `json:"prompt"`
Model string `json:"model,omitempty"`
}
5 changes: 3 additions & 2 deletions cmd/entire/cli/checkpoint/checkpoint.go
Original file line number Diff line number Diff line change
Expand Up @@ -387,8 +387,9 @@ type CommittedMetadata struct {
// Agent identifies the agent that created this checkpoint (e.g., "Claude Code", "Cursor")
Agent types.AgentType `json:"agent,omitempty"`

// Model is the LLM model used during the session (e.g., "claude-sonnet-4-20250514")
Model string `json:"model,omitempty"`
// Model is the LLM model used during the session (e.g., "claude-sonnet-4-20250514").
// Always written to metadata (empty string when unknown) so consumers can rely on the field's presence.
Model string `json:"model"`

// TurnID correlates checkpoints from the same agent turn.
// When a turn's work spans multiple commits, each gets its own checkpoint
Expand Down
83 changes: 83 additions & 0 deletions cmd/entire/cli/checkpoint/checkpoint_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -2871,6 +2871,89 @@ func TestWriteCommitted_CLIVersionField(t *testing.T) {
}
}

func TestWriteCommitted_ModelFieldAlwaysPresent(t *testing.T) {
t.Parallel()

tempDir := t.TempDir()

repo, err := git.PlainInit(tempDir, false)
if err != nil {
t.Fatalf("failed to init git repo: %v", err)
}

worktree, err := repo.Worktree()
if err != nil {
t.Fatalf("failed to get worktree: %v", err)
}

readmeFile := filepath.Join(tempDir, "README.md")
if err := os.WriteFile(readmeFile, []byte("# Test"), 0o644); err != nil {
t.Fatalf("failed to write README: %v", err)
}
if _, err := worktree.Add("README.md"); err != nil {
t.Fatalf("failed to add README: %v", err)
}
if _, err := worktree.Commit("Initial commit", &git.CommitOptions{
Author: &object.Signature{Name: "Test", Email: "test@test.com"},
}); err != nil {
t.Fatalf("failed to commit: %v", err)
}

store := NewGitStore(repo)

checkpointID := id.MustCheckpointID("c1d2e3f4a5b6")
err = store.WriteCommitted(context.Background(), WriteCommittedOptions{
CheckpointID: checkpointID,
SessionID: "test-session-model",
Strategy: "manual-commit",
Agent: agent.AgentTypeClaudeCode,
Transcript: []byte("test transcript"),
AuthorName: "Test Author",
AuthorEmail: "test@example.com",
})
if err != nil {
t.Fatalf("WriteCommitted() error = %v", err)
}

ref, err := repo.Reference(plumbing.NewBranchReferenceName(paths.MetadataBranchName), true)
if err != nil {
t.Fatalf("failed to get metadata branch reference: %v", err)
}

commit, err := repo.CommitObject(ref.Hash())
if err != nil {
t.Fatalf("failed to get commit object: %v", err)
}

tree, err := commit.Tree()
if err != nil {
t.Fatalf("failed to get tree: %v", err)
}

sessionMetadataPath := checkpointID.Path() + "/0/" + paths.MetadataFileName
sessionMetadataFile, err := tree.File(sessionMetadataPath)
if err != nil {
t.Fatalf("failed to find session metadata.json at %s: %v", sessionMetadataPath, err)
}

sessionContent, err := sessionMetadataFile.Contents()
if err != nil {
t.Fatalf("failed to read session metadata.json: %v", err)
}

var sessionMetadata CommittedMetadata
if err := json.Unmarshal([]byte(sessionContent), &sessionMetadata); err != nil {
t.Fatalf("failed to parse session metadata.json: %v", err)
}

if sessionMetadata.Model != "" {
t.Errorf("CommittedMetadata.Model = %q, want empty string", sessionMetadata.Model)
}
if !strings.Contains(sessionContent, `"model": ""`) {
t.Errorf("session metadata.json should contain an explicit empty model field, got:\n%s", sessionContent)
}
}

func TestRedactSummary_Nil(t *testing.T) {
t.Parallel()
result := redactSummary(nil)
Expand Down
1 change: 1 addition & 0 deletions e2e/testutil/metadata.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ type SessionMetadata struct {
CreatedAt time.Time `json:"created_at"`
Branch string `json:"branch"`
Agent string `json:"agent"`
Model string `json:"model"`
CheckpointsCount int `json:"checkpoints_count"`
FilesTouched []string `json:"files_touched"`
TokenUsage TokenUsage `json:"token_usage"`
Expand Down
4 changes: 4 additions & 0 deletions e2e/vogon/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -47,6 +47,7 @@ func main() {
fireHook(dir, "session-start", map[string]string{
"session_id": sessionID,
"transcript_path": transcriptPath,
"model": "vogon-llm-42",
})

if prompt != "" {
Expand Down Expand Up @@ -76,6 +77,7 @@ func main() {
fireHook(dir, "session-end", map[string]string{
"session_id": sessionID,
"transcript_path": transcriptPath,
"model": "vogon-llm-42",
})
}

Expand All @@ -84,6 +86,7 @@ func runTurn(dir, sessionID, transcriptPath, prompt string) {
"session_id": sessionID,
"transcript_path": transcriptPath,
"prompt": prompt,
"model": "vogon-llm-42",
})

appendTranscript(transcriptPath, "user", prompt)
Expand All @@ -97,6 +100,7 @@ func runTurn(dir, sessionID, transcriptPath, prompt string) {
fireHook(dir, "stop", map[string]string{
"session_id": sessionID,
"transcript_path": transcriptPath,
"model": "vogon-llm-42",
})
}

Expand Down
Loading