Skip to content
Draft
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
3 changes: 3 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -137,6 +137,9 @@ langsmith trace list --project my-app --full # All fields (metadat
# Show trace hierarchy (fetches full run tree for each trace)
langsmith trace list --project my-app --show-hierarchy --limit 3

# Ask the server for at most one trace per non-empty thread_id
langsmith trace list --project my-app --one-per-thread

# Get a specific trace
langsmith trace get <trace-id> --project my-app --full

Expand Down
5 changes: 3 additions & 2 deletions internal/cmd/helpers.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,13 +9,14 @@ import (
"github.com/langchain-ai/langsmith-cli/internal/extract"
"github.com/langchain-ai/langsmith-cli/internal/output"
langsmith "github.com/langchain-ai/langsmith-go"
"github.com/langchain-ai/langsmith-go/option"

"github.com/google/uuid"
)

// queryRuns queries runs with the given params and optional session resolution.
// minTokens > 0 enables client-side filtering by total_tokens (not supported server-side).
func queryRuns(ctx context.Context, c *client.Client, params langsmith.RunQueryParams, projectName string, limit int, minTokens int) ([]langsmith.RunSchema, error) {
func queryRuns(ctx context.Context, c *client.Client, params langsmith.RunQueryParams, projectName string, limit int, minTokens int, opts ...option.RequestOption) ([]langsmith.RunSchema, error) {
// Resolve project name → session ID
if projectName != "" {
sessionID, err := c.ResolveSessionID(ctx, projectName)
Expand All @@ -29,7 +30,7 @@ func queryRuns(ctx context.Context, c *client.Client, params langsmith.RunQueryP
remaining := limit

for {
resp, err := c.SDK.Runs.Query(ctx, params)
resp, err := c.SDK.Runs.Query(ctx, params, opts...)
if err != nil {
return nil, fmt.Errorf("querying runs: %w", err)
}
Expand Down
9 changes: 8 additions & 1 deletion internal/cmd/trace.go
Original file line number Diff line number Diff line change
Expand Up @@ -9,6 +9,7 @@ import (

"github.com/langchain-ai/langsmith-cli/internal/output"
langsmith "github.com/langchain-ai/langsmith-go"
"github.com/langchain-ai/langsmith-go/option"
"github.com/spf13/cobra"
)

Expand Down Expand Up @@ -47,6 +48,7 @@ func newTraceListCmd() *cobra.Command {
includeFlagged bool
full bool
showHierarchy bool
onePerThread bool
outputFile string
)

Expand Down Expand Up @@ -76,7 +78,11 @@ func newTraceListCmd() *cobra.Command {
if sel := buildRunSelect(includeIO, includeFeedback); sel != nil {
params.Select = langsmith.F(sel)
}
runs, err := queryRuns(ctx, c, params, projectName, ff.Limit, ff.MinTokens)
var queryOpts []option.RequestOption
if onePerThread {
queryOpts = append(queryOpts, option.WithJSONSet("one_per_thread", true))
}
runs, err := queryRuns(ctx, c, params, projectName, ff.Limit, ff.MinTokens, queryOpts...)
if err != nil {
ExitErrorf("%v", err)
}
Expand Down Expand Up @@ -152,6 +158,7 @@ func newTraceListCmd() *cobra.Command {
cmd.Flags().BoolVar(&includeFlagged, "include-flagged", false, "Add flagged_comment field populated from user-flagged trace feedback")
cmd.Flags().BoolVar(&full, "full", false, "Shorthand for --include-metadata --include-io --include-feedback")
cmd.Flags().BoolVar(&showHierarchy, "show-hierarchy", false, "Fetch the full run tree for each trace")
cmd.Flags().BoolVar(&onePerThread, "one-per-thread", false, "Ask the server to return at most one trace per non-empty thread_id")
cmd.Flags().StringVarP(&outputFile, "output", "o", "", "Write JSON output to a file")

return cmd
Expand Down
84 changes: 84 additions & 0 deletions internal/cmd/trace_test.go
Original file line number Diff line number Diff line change
@@ -1,6 +1,9 @@
package cmd

import (
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
)

Expand Down Expand Up @@ -41,6 +44,7 @@ func TestTraceListCmd_Flags(t *testing.T) {
{"include-io", "false", ""},
{"full", "false", ""},
{"show-hierarchy", "false", ""},
{"one-per-thread", "false", ""},
{"output", "", "o"},
}
for _, tc := range tests {
Expand Down Expand Up @@ -76,6 +80,86 @@ func TestTraceListCmd_NoRunTypeFlag(t *testing.T) {
}
}

// traceListTestServer returns a handler that mocks /sessions and /runs/query.
// sessions maps project name to session ID. requestBodies captures every
// /runs/query request body.
func traceListTestServer(t *testing.T, sessions map[string]string, requestBodies *[]map[string]any) *httptest.Server {
t.Helper()
return newTestServer(t, func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
switch {
case r.URL.Path == "/api/v1/sessions" && r.Method == "GET":
name := r.URL.Query().Get("name")
id, ok := sessions[name]
if !ok {
w.WriteHeader(http.StatusNotFound)
return
}
_ = json.NewEncoder(w).Encode([]map[string]any{{"id": id, "name": name}})
case r.URL.Path == "/api/v1/runs/query" && r.Method == "POST":
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decoding runs query body: %v", err)
}
*requestBodies = append(*requestBodies, body)
_ = json.NewEncoder(w).Encode(map[string]any{
"runs": []map[string]any{{
"id": "run-1",
"trace_id": "trace-1",
"name": "agent",
"run_type": "chain",
"start_time": "2026-01-01T00:00:00Z",
}},
})
default:
http.Error(w, "not found", http.StatusNotFound)
}
})
}

func TestTraceListCmd_OnePerThreadSendsServerParam(t *testing.T) {
var requestBodies []map[string]any
ts := traceListTestServer(t, map[string]string{"my-app": "session-123"}, &requestBodies)
cleanup := setupTestEnv(t, ts.URL)
defer cleanup()
flagOutputFormat = "json"

cmd := newTraceListCmd()
_ = cmd.Flags().Set("project", "my-app")
_ = cmd.Flags().Set("one-per-thread", "true")
_ = cmd.Flags().Set("limit", "1")

_ = captureStdout(t, func() { cmd.Run(cmd, nil) })

if len(requestBodies) != 1 {
t.Fatalf("expected 1 runs query request, got %d", len(requestBodies))
}
if got := requestBodies[0]["one_per_thread"]; got != true {
t.Fatalf("expected one_per_thread=true, got %#v in %#v", got, requestBodies[0])
}
}

func TestTraceListCmd_OnePerThreadDefaultOmitsServerParam(t *testing.T) {
var requestBodies []map[string]any
ts := traceListTestServer(t, map[string]string{"my-app": "session-123"}, &requestBodies)
cleanup := setupTestEnv(t, ts.URL)
defer cleanup()
flagOutputFormat = "json"

cmd := newTraceListCmd()
_ = cmd.Flags().Set("project", "my-app")
_ = cmd.Flags().Set("limit", "1")

_ = captureStdout(t, func() { cmd.Run(cmd, nil) })

if len(requestBodies) != 1 {
t.Fatalf("expected 1 runs query request, got %d", len(requestBodies))
}
if _, ok := requestBodies[0]["one_per_thread"]; ok {
t.Fatalf("expected one_per_thread to be omitted, got %#v", requestBodies[0])
}
}

// ==================== trace get flags ====================

func TestTraceGetCmd_Flags(t *testing.T) {
Expand Down
Loading