diff --git a/README.md b/README.md index add63ed..a8d0f7c 100644 --- a/README.md +++ b/README.md @@ -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 --project my-app --full diff --git a/internal/cmd/helpers.go b/internal/cmd/helpers.go index 8eccdff..0083541 100644 --- a/internal/cmd/helpers.go +++ b/internal/cmd/helpers.go @@ -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) @@ -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) } diff --git a/internal/cmd/trace.go b/internal/cmd/trace.go index 8818fbb..5970ea6 100644 --- a/internal/cmd/trace.go +++ b/internal/cmd/trace.go @@ -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" ) @@ -47,6 +48,7 @@ func newTraceListCmd() *cobra.Command { includeFlagged bool full bool showHierarchy bool + onePerThread bool outputFile string ) @@ -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) } @@ -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 diff --git a/internal/cmd/trace_test.go b/internal/cmd/trace_test.go index e0dd375..94ea90c 100644 --- a/internal/cmd/trace_test.go +++ b/internal/cmd/trace_test.go @@ -1,6 +1,9 @@ package cmd import ( + "encoding/json" + "net/http" + "net/http/httptest" "testing" ) @@ -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 { @@ -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) {